Skip to content

Instantly share code, notes, and snippets.

@g33kChris
Last active October 3, 2022 18:54
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save g33kChris/38794dd909e9a77a02f70487ccac655c to your computer and use it in GitHub Desktop.
Save g33kChris/38794dd909e9a77a02f70487ccac655c to your computer and use it in GitHub Desktop.
Using React Context to manage global state
// Wrap the global state provider around the root of your app.
ReactDOM.render(
<StateProvider>
<App />
</StateProvider>,
document.getElementById('root')
);
// All the state for the application.
export interface ApplicationState {
isExpanded: boolean;
userMode: string;
scrollPosition: number;
}
import { ApplicationState } from './application-state.interface';
// The default state for the application.
export const InitialState: ApplicationState = {
isExpanded: false,
userMode: 'default',
scrollPosition: 0
}
import { StateAction } from '../state-action.interface';
// Exposing the reducer's action types (so we're not passing string literals around).
export const isExpandedActionTypes = {
EXPAND: 'EXPAND',
COLLAPSE: 'COLLAPSE'
}
// Basic reducer to set a boolean state for expand/collapse.
export function isExpandedReducer(state: boolean = false, action: StateAction): boolean {
switch(action.type) {
case isExpandedActionTypes.EXPAND: {
return true;
}
case isExpandedActionTypes.COLLAPSE: {
return false
}
default:
return state;
}
}
export interface MenuProps {
state: ApplicationState;
dispatch: ({ type }: { type: string; payload?: any; }) => void
}
const Menu = (props: MenuProps) => {
const toggleMenu = () => {
// GLOBAL STATE: using the user mode to dispatch the correct expand/collapse state
if (props.state.userMode === 'default') {
props.dispatch({ type: props.state.isExpanded
? isExpandedActionTypes.COLLAPSE
: isExpandedActionTypes.EXPAND
})
} else {
props.dispatch({ type: userModeActionTypes.SET_USER_MODE, payload: 'default' })
props.dispatch({ type: isExpandedActionTypes.COLLAPSE })
}
};
const toggleSearch = (searchModeEnabled: boolean) => {
// GLOBAL STATE: Dispatching a state change for the user mode (search)
props.dispatch({ type: userModeActionTypes.SET_USER_MODE, payload: searchModeEnabled ? '' : 'search' });
}
const toggleSettings = (settingModeEnabled: boolean) => {
// GLOBAL STATE: Dispatching a state change for the user mode (settings)
props.dispatch({ type: userModeActionTypes.SET_USER_MODE, payload: settingModeEnabled ? '' : 'settings' });
}
// GLOBAL STATE: Mapping the user mode state to component specific booleans
const searchModeEnabled = props.state.userMode === 'search';
const settingsModeEnabled = props.state.userMode === 'settings';
const getDocHeight = () => {
return Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight
);
}
const calculateScrollDistance = () => {
const scrollTop = window.pageYOffset; // how much the user has scrolled by
const winHeight = window.innerHeight;
const docHeight = getDocHeight();
const totalDocScrollLength = docHeight - winHeight;
const newPosition = Math.floor(scrollTop / totalDocScrollLength * 100)
// GLOBAL STATE: Setting the scroll position in state
props.dispatch({ type: scrollPositionActionTypes.SET_SCROLL_POSITION, payload: newPosition });
}
const listenToScrollEvent = () => {
document.addEventListener("scroll", () => {
requestAnimationFrame(() => {
calculateScrollDistance();
});
});
}
useEffect(() => {
listenToScrollEvent();
}, []);
return (
<>
<Root
// GLOBAL STATE: Using the global state to drive the UI
isExpanded={props.state.isExpanded}
searchModeEnabled={searchModeEnabled}
settingsModeEnabled={settingsModeEnabled}
data-testid="menu"
>
<MobileControls>
<BurgerButton
isExpanded={
(props.state.isExpanded || searchModeEnabled || settingsModeEnabled)
}
onClick={toggleMenu}
>
<div />
<div />
<div />
</BurgerButton>
</MobileControls>
{ searchModeEnabled &&
<SearchResults>
<SearchHeader>Search</SearchHeader>
</SearchResults>
}
{ settingsModeEnabled &&
<Settings>
<SettingsHeader>Settings</SettingsHeader>
</Settings>
}
<Controls>
<ControlButton onClick={() => toggleSearch(searchModeEnabled)} active={searchModeEnabled}>
<SearchIcon />
</ControlButton>
<ControlButton onClick={() => toggleSettings(settingsModeEnabled)} active={settingsModeEnabled}>
<SettingsIcon icon={resolveIcon('settings')} />
</ControlButton>
</Controls>
</Root>
<ScrollBar scroll={props.state.scrollPosition} />
</>
);
};
export default withApplicationState(Menu);

If you want to skip the blog post and go through the code for yourself here's the recommended reading order...

  • application-state.interface.ts and initial-state.ts (understand the state being used)
  • state-action.interface.ts
  • root.reducer.ts (the main integration point for all state reducers)
    • The other reducers show a more granualar breakdown of reducing state in the application, the state in each of the reducers are quite primitive.
  • state.provider.ts (creation of the react context and state object)
  • app.tsx (the main integration point bridging the context provider and the react component tree)
  • with-application-state.tsx (how to wrap state at any point in the component tree)
  • tags.component.ts (basic usage of the application state dispatcher within a component)
  • menu.component.tsx (a more sophisticated usage of the application state and dispatcher)
import { ApplicationState } from './application-state.interface';
import { StateAction } from './state-action.interface';
import { isExpandedReducer } from './reducers/is-expanded.reducer';
import { userModeReducer } from './reducers/user-mode.reducer';
import scrollPositionReducer from './reducers/scroll-position.reducer';
// A root-level reducer to capture all dispatched actions within the application
export default function rootReducer(state: ApplicationState, action: StateAction): ApplicationState {
const { isExpanded, userMode, scrollPosition } = state;
return {
isExpanded: isExpandedReducer(isExpanded, action),
userMode: userModeReducer(userMode, action),
scrollPosition: scrollPositionReducer(scrollPosition, action)
}
}
import { StateAction } from '../state-action.interface';
// Exposing the reducer's action types (so we're not passing string literals around).
export const scrollPositionActionTypes = {
SET_SCROLL_POSITION: 'SET_SCROLL_POSITION'
}
// Basic reducer to set the scroll position value
export default function scrollPositionReducer(state: number = 0, action: StateAction): number {
switch(action.type) {
case scrollPositionActionTypes.SET_SCROLL_POSITION: {
return action.payload;
}
default:
return state;
}
}
// A generic typescript interface to capture state based actions.
export interface StateAction {
type: string;
payload: any;
}
import * as React from 'react';
import { ApplicationState } from './application-state.interface';
import rootReducer from './root.reducer';
import { initialState } from './initial-state';
// Interface to define the basic props for the provider component
interface StateProviderProps {
children: any;
}
// Interface to define to state of the context object.
interface IStateContext {
state: ApplicationState;
dispatch: ({type}:{type:string}) => void;
}
// A basic empty context object.
export const GlobalStore = React.createContext({} as IStateContext);
// An wrapping function to handle thunks (dispatched actions which are wrapped in a function, needed for async callbacks)
const asyncer = (dispatch: any, state: ApplicationState) => (action: any) =>
typeof action === 'function' ? action(dispatch, state) : dispatch(action);
// The StateProvider component to provide the global state to all child components
export function StateProvider(props: StateProviderProps) {
const [state, dispatchBase] = React.useReducer(rootReducer, initialState);
const dispatch = React.useCallback(asyncer(dispatchBase, state), [])
return (
<GlobalStore.Provider value={{ state, dispatch }}>
{ props.children }
</GlobalStore.Provider>
)
}
import * as React from 'react';
import { ApplicationState } from '../../../providers/application-state.interface';
import { isExpandedActionTypes } from '../../../providers/reducers/is-expanded.reducer';
import { userModeActionTypes } from '../../../providers/reducers/user-mode.reducer';
import { Tags as Root, Tag as Item } from '../../primitives';
import withApplicationState from '../../_hocs/with-application-state';
interface TagsProps {
tags: string[]
state: ApplicationState;
dispatch: ({ type }: { type: string; payload?: any; }) => void
}
function Tags(props: TagsProps) {
function search(tag: string) {
// GLOBAL STATE: Triggering the expand/collapse search box from the tag component (a different part of the component tree!)
props.dispatch({ type: isExpandedActionTypes.EXPAND });
props.dispatch({ type: userModeActionTypes.SET_USER_MODE, payload: 'search' });
}
return (
<Root>
{
props.tags.map(t => (
<Item>
<button onClick={() => search(t)}>{ t }</button>
</Item>
))
}
</Root>
)
}
export default withApplicationState(Tags);
import { StateAction } from '../state-action.interface';
// Exposing the reducer's action types (so we're not passing string literals around).
export const userModeActionTypes = {
SET_USER_MODE: 'SET_USER_MODE'
}
// Basic reducer to set a string literal user mode
export function userModeReducer(state: string = 'default', action: StateAction): string {
switch(action.type) {
case userModeActionTypes.SET_USER_MODE: {
return action.payload;
}
default:
return state;
}
}
import React from 'react';
import { GlobalStore } from '../../providers/state.provider';
// A higher order component to inject the state and dispatcher
export default function withApplicationState(Component: any) {
return function WrapperComponent(props: any) {
return (
<GlobalStore.Consumer>
{context => <Component {...props} state={context.state} dispatch={context.dispatch} />}
</GlobalStore.Consumer>
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment