Using react context to manage state (including TypeScript)

  • Chris Wallace
  • Tue Dec 31 2019

React context now provides an adequate solution for small-scale state management. In this post I walk you through setting it up in your react application.

When react rewrote their context API back in summer 2018, it brought with it an accessible API for general react developers. Before the rewrite, the react context was considered an API for plugin development only. Fortunately this is no longer the case, and when coupled with react hooks they give us an entry level solution for state management.

Before the react context, react development relied on 3rd party solutions such as redux and mobx for an adequate solution to global state. This is no longer the case with react context and state hooks, and as a first approach it's faster to get a flux based state management solution working with react's out of the box solution. Redux has a lot of moving parts which may not be necessary when starting a new application. In this article I will walk you through the parts required to get a global react state solution setup in TypeScript based react app.

Why global state?

Making domain level or presentation level data available at any point in your application reduces the complexity of the codebase. Similar to injecting dependencies, having a global container holding our state removes the concerns around passing data. In react this is an important asset because react applications are based around complex and deep component trees.

Without connected components, data would have to be passed deeply from parent to child (also known as 'prop drilling'). This is a messy practice and would involve unrelated data being handled within unrelated components.

Rather than having something like this...

prop drilling

...we can use global state to achieve something like this.

global state

Components at different levels have access to the same data, bypassing unrelated components.

The Structure

Here's all the typescript modules involved in this solution. Some of these are merely typescript boilerplate to help define the structure of the state and actions we're using, but they'll prove useful when building the state management solution.

state management diagram

  • ApplicationState + Initial State
  • State Action
  • State reducers + root reducer
  • State Provider
  • with-application-state higher order component
  • App.tsx entry
  • Consuming components

Let's start with the basics first

Application State + Initial State

// All the state for the application.
export interface ApplicationState {
isExpanded: boolean;
userMode: string;
scrollPosition: number;
}

First we define the shape of our application state. In the above example I've created a TypeScript interface describing the properties available within global state. The state has two properties, 'isExpanded', 'userMode'. These two properties are properties specific to the application which could be used across the component tree, there's no specific domain within the app so makes sense for them to be available globally.

Along with the interface, we also have some initial state definitions.

import { ApplicationState } from './application-state.interface';
// The default state for the application.
export const InitialState: ApplicationState = {
isExpanded: false,
userMode: 'default',
scrollPosition: 0
}
view raw initial-state.ts hosted with ❤ by GitHub

State Action

If you've used redux, you'll be familiar with the dispatcher based pattern used to set state. Even though we're not using redux, we can still apply the same pattern here. To help with defining our dispatcher pattern, we can define a common interface for actions.

// A generic typescript interface to capture state based actions.
export interface StateAction {
type: string;
payload: any;
}

Each action is fairly simple, it has a string to define the type, and a payload property for providing values to store in state. Now we need to write some reducers to consume this action interface.

Property level reducers

For each property in the application state, we can define some reducers. Similar to redux reducers, the purpose of these are to handle the dispatched actions and conditionally set values in state.

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;
}
}
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;
}
}
view raw user-mode.reducer.ts hosted with ❤ by GitHub

Now we have reducers for the properties, we need to combine them.

Root level reducer

The root reducer acts as an entry-point for the state management reducers. We could of course have a single reducer doing 'all the things' but the example approach allows us to extend the root reducer over time and maintain parts of the application state on a smaller scale.

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)
}
}
view raw root.reducer.ts hosted with ❤ by GitHub

Now we have a solution to set state, we need an integration point to get state within the application component tree.

State Provider

We now need a state provider component to setup our global state and make it available as part of the component tree.

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>
)
}
view raw state.provider.ts hosted with ❤ by GitHub

We create an interface for the 'IStateContext', then create a 'GlobalStore' object using the 'createContext' API. The State context has an instance of our 'ApplicationState' and a dispatcher, which will then made available throughout our component tree (because it's created using the react context). Finally we encapsulate the store within a 'StateProvider' component (a wrapping component), which assigns an initial value for both the 'state' object and the 'dispatcher'. The initial value for the state use created using the root reducer we defined prior. We now have a Global State object and dispatcher ready to be bootstrapped to our Application

App.tsx

In order to bootstrap the Global State to our app, we simply wrap it around the root of our application. The State Provider component has 'children' props defined by default, so to make the application state ready we need to wrap the root of the application with our provider.

// Wrap the global state provider around the root of your app.
ReactDOM.render(
<StateProvider>
<App />
</StateProvider>,
document.getElementById('root')
);
view raw app.tsx hosted with ❤ by GitHub

Our application has state, but how do our components access that state?

with-application-state Higher Order Component

Using a higher order component, we can make any component at any point in the component tree have access to our global state. If you're unsure what higher order components are, it's worth having a read (checkout my 'resources' page for useful reads). For now, all you need to know is that it wraps a component inside another component and 'sprinkles' on additional prop values.

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>
);
}
}

In our example, we leverage the 'Global Store Consumer' object available from the react context to wrap any component. Using the Consumer gives us access to the 'context' object and inject the global 'state' object and 'dispatch' function for our component to consume. So long as the consuming component has awareness of 'state' and 'dispatch', they can be used to get and set state (see the next section for examples). This pattern is also very clean when writing tests, as our consuming component can use stubbed or mocked 'state' and 'dispatch' props as dependencies and not require any knowledge of the context object at all.

Now we've setup a higher order component to connect components to state, how is it used?

Consuming components

Now we have our higher order component to connect components to global state, we can use it with our application's components. Our first usage is for a 'Tags' component.

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);
view raw tags.component.tsx hosted with ❤ by GitHub

Our component defines the props required, including the 'state' and 'dispatch' from our global context. Then within our component we use the 'dispatch' function to dispatch a couple of state setting actions (EXPAND and SETUSERMODE). These actions control an expand/collapse menu and the content conditionally rendered inside a menu. Note that we're also using the Action Types defined in our property reducers (so as not to use string literals).

Our second example (a menu component) is more complex but applies the same principles.

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);
view raw menu.component.tsx hosted with ❤ by GitHub

Although this component is larger than the Tags component, it follows the same approach. The Menu component defines the props required, including the 'state' and 'dispatch' from our global context. The state object is then used to drive the expand/collapse and conditional rendering behavior of the component. It also has different buttons to dispatch specific actions (controlling the expand/collapse state of the app, and the conditional rendering inside the menu).

With all these pieces in place. We now have two separate components in two separate parts of the component driving common behavior globally in the app.

One driven by the tags...

...and another driven by the menu.

I hope you found this walkthrough useful and has helped you get up and running with global react state. All the example code for this article can be found here.