React state with context and custom hooks
May 9, 2020
I spent the weekend working on my site, updating the design and making it a better reflection of me as a developer and person. I'm not a very good designer, or rather, I'm not very good at picturing a design in my mind's eye; I have to put the design on the screen, change it, break it and generally experiment my way to something I don't feel ashamed to publish.
This design was no exception. I started poking around in the gatsby-sanity starter after I finished refactoring everything to TypeScript and noticed that the images from Sanity come with some palette metadata information, so I figured I'd try to use that metadata to set global colours around the site. In the past, I would have installed Redux and dispatched actions on component mount to update the store, which in turn would propagate down the tree. It's incredible to think about how much React has changed since the introduction of hooks and the context API. In this post, I'll show you how to set up a typed context to handle your application state and update them with the help of custom hooks.
Why React Context?
React context is essentially a special React component that you create and mount higher up in the tree. You can then hook into the context to access data further down in the tree without drilling props from one component to another. It helps developers create applications with flows that are easy to follow.
Why hooks?
The context API predates the addition of hooks in React, that means you don't have to use hooks to use context; however, hooks make it far neater and allows you to create custom hooks with reusable logic.
Create the context
The first step you need to take is to create the context.
export const Context = React.createContext({});
When you initialise a Context
, you can give it an initial state. In the example above, I set the initial state to an empty object. We're going to change that soon, but let's leave it for now.
You may also have noticed the export in front of the context declaration, that's because you'll use it to consume the context later. However, before you can consume the context, you need to create a Provider
and wrap your component tree with it.
export function Provider({ children }) {
return (
<Context.Provider
value={{}}
>
{children}
</Context.Provider>
);
}
There are two things to note here; the first is that the Provider
accepts a children
prop which is then wrapped by the Context.Provider
; the second is that the returned Context.Provider
has a value
prop which you initialised to an empty object. That empty object is where you'll export the state
and actions
from the context so your custom hooks can consume them.
Add types
With the basic context in place, it's time to create some types that you'll use when you consume the context. Let's start by importing ReactElement
from React and use it to type the Provider
props.
type ProviderProps = {
children: React.ReactElement | React.ReactElement[];
};
You type children
as either a single React element or an array of them. That's because you might wrap a single React element with the Provider
, or you might wrap multiple elements.
You'll also need a ContextProps
type; it's used whenever you consume the context.
type State = {
lightSwitch: boolean;
counter: number;
message: string;
}
type ContextProps = {
dispatch: React.Dispatch<any>;
state: State;
};
Hang on a second here. You just initialised the context with something you hadn't even created yet. There is no state, and what is that dispatch thing? Well, it comes from a React hook called useReducer
, and you'll use it to control the state flow in your app. Let's hop in there before you add any more types for code you haven't even written yet.
Handle state with useReducer
Why should you useReducer
when you could useState
? There are many reasons, and I'm sure people that are far smarter than me have far better reasons. But in my mind, it's a lot more simplistic. Imagine for a second that you work on an application for a while, your global state starts to grow, and you find that you have multiple useState
hooks that each handle their state variables. It quickly becomes a mess, especially if there is some interdependency between them as state updates could get out of sync.
It's up to you if you want to initialise the useReducer
state with an initial state value; however, in this case, you will.
const initialState: State = {
lightSwitch: false,
counter: 0,
message: null
}
function reducer(state: State, action: Action): State {
// Handle the dispatched actions
}
export function Provider({ children }: ProviderProps) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<Context.Provider
value={{
state,
dispatch,
}}
>
{children}
</Context.Provider>
);
}
The reducer
function always takes two arguments: state
and action
. The state
argument is the current reducer state, and the action
contains the dispatched values.
The useReducer
hook returns a tuple, just like the useState
hook, where the first value contains the state variable and the second is the setter function. In this case, an action dispatcher.
Create actions
When you look at examples of reducers in React, you'll find that most use a switch
to handle the logic, and there's nothing wrong with that, switches work perfectly fine and provide a default case to return the unchanged state. However, I prefer to keep the reducer function itself clear of that logic and move it out to functions. Here's how I handle that.
export enum Actions {
toggleLightSwitch = 'toggleLightSwitch',
setMessage = 'setMessage',
increaseCounter = 'increaseCounter',
decreaseCounter = 'decreaseCounter'
}
type ActionFunction = (state: State, payload: Payload) => State;
const actions: Record<Actions, ActionFunction> = {
toggleLightSwitch: (state: State) => ({ ...state, lightSwitch: !state.lightSwitch }),
setMessage: (state: State, payload: string) => ({
...state,
message: payload,
}),
increaseCounter: (state: payload?: number) => ({
...state,
counter: state.counter += payload || 1
}),
decreaseCounter: (state: payload?: number) => ({
...state,
counter: state.counter -= payload || 1
})
};
function reducer(state: State, action: Action): State {
return actions[action.type](state, action.payload) || state;
}
The action object above doesn't need to have the functions declared as properties. They can be defined anywhere and referenced on the actions object when your application logic grows. I'll leave it up to you to decide if you want to handle the action logic like above or with returns in switch
statements. Just remember to return the full state object.
That's it for the context file. Below is how it all looks together.
import React from 'react';
type ProviderProps = {
children: React.ReactElement | React.ReactElement[];
};
type State = {
lightSwitch: boolean;
counter: number;
message: string;
}
type ContextProps = {
dispatch: React.Dispatch<any>;
state: State;
};
type Payload = string | number;
type ActionFunction = (state: State, payload: Payload) => State;
export enum Actions {
toggleLightSwitch = 'toggleLightSwitch',
setMessage = 'setMessage',
increaseCounter = 'increaseCounter',
decreaseCounter = 'decreaseCounter'
}
const initialState: State = {
lightSwitch: false,
counter: 0,
message: null
}
export const Context = createContext<Partial<ContextProps>>({
state: initialState,
});
const actions: Record<Actions, ActionFunction> = {
toggleLightSwitch: (state: State) => ({ ...state, lightSwitch: !state.lightSwitch }),
setMessage: (state: State, payload: string) => ({
...state,
message: payload,
}),
increaseCounter: (state: payload?: number) => ({
...state,
counter: state.counter += payload || 1
}),
decreaseCounter: (state: payload?: number) => ({
...state,
counter: state.counter -= payload || 1
})
};
function reducer(state: State, action: Action): State {
return actions[action.type](state, action.payload) || state;
}
export function Provider({ children }: ProviderProps) {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<Context.Provider
value={{
state,
dispatch,
}}
>
{children}
</Context.Provider>
);
}
Create custom hooks
To consume the context, you use React's useContext
hook, which returns the value prop you passed to the context provider. In this case, it's an object with state
and dispatch
.
const { state, dispatch } = React.useContext(Context);
To trigger a state update, you dispatch an action.
dispatch({ type: Actions.toggleLightSwitch })
I don't know about you, but I prefer to abstract away the dispatch, and I like to do that in a custom hook. In React, all hooks should begin with the word "use". There is nothing to enforce this practice, but the React docs strongly recommend it as a way of showing that the function follows the rules of hooks.
import React from 'react';
import { Context, Actions } from '../path/to/context';
export type CounterActions = {
increaseCounter: (value?: number) => void;
decreaseCounter: (value?: number) => void
};
export function useCounter(): { count: number; actions: CounterActions } {
const { state, dispatch } = React.useContext(Context);
const increaseCounter = (value?: number) =>
dispatch({ type: Actions.increaseCounter, payload: value });
const decreaseCounter = (value?: number) =>
dispatch({ type: Actions.decreaseCounter, payload: value });
return {
counter state.counter,
actions: {
increaseCounter,
decreaseCounter,
},
};
}
The example above uses the counter as an example, but you can easily adapt it to any or all of the actions you can dispatch. I prefer to separate the hooks. In general, my approach is always to make each part as small as possible as it makes things like naming, testing and understanding much easier. You quickly get an understanding of this hook by looking at it, but if you were to cram in all the other dispatch actions here it wouldn't be as straight forward.
Use the custom hooks
Now that you have the hooks, how do you use them? It's pretty simple; you import the custom hook and destructure the return value to get the state value and the actions. When you run one of the actions, your context updates and in turn triggers a rerender of the hook with a new state value for counter, this will, in turn, cause your dependent component to rerender as well.
import React from 'react'
import { useCounter } from '../path/to/hook'
export function Component() {
const { counter, actions: { increaseCounter, decreaseCounter } } = useCounter()
return (
<>
<p>{ counter }</p>
<button onClick={increaseCounter}>Increase</button>
<button onClick={decreaseCounter}>Decrease</button>
</>
)
}
Conclusion
I hope this post has shown you a new way of maintaining application state with useReducer
and custom hooks. Types help you to access the state properties around the app and ensure you pass the correct values to each function as TypeScript yells at you if you don't.
These tools together get you a fair bit on the way toward writing application state logic that scales.