The React useReducer hook is one of the less used and under rated built-in hook but it can be especially useful when we use several useState hooks in a component that are related to each other.
The concept is very simple:
dispatch an action to update the statedispatch and the state is updated accordinglyuseReducerThe useReducer hook accepts at least two parameters:
and returns the current state and the dispatch function:
const [state, dispatch] = useReducer(reducerFn, initialState);
If you have never used the useReducer hook before I highly recommend you to read my article
The first step is the creation of a custom type to represents the state:
interface AppState {
counter: number; // a simple counter
random: number; // a random value
}
You can emit an action by using the dispatch function returned by the useReducer hook (we'll discuss about it later in this post).
An "action" can be a simple string as shown below:
dispatch('increment')
But, by convention, it's common to pass an object with the type property and, if necessary, a payload:
dispatch({type: 'increment', payload: 10})
The previous action can be represented by the following type:
type AppAction = {
type: string;
payload: number;
}
Or we can use literal types in order to define exactly what string we should pass:
type AppAction = {
type: 'increment';
payload: number;
}
Since you may need to handle several actions in order to update the same state, you can use the Union type to define what types of actions can be accepted by the type property:
type AppActions = {
type: 'increment' | 'decrement';
payload: number;
}
But how can we handle the scenario where each action has a different payload (i.e. a string and a number) or doesn't have it at all?
In fact our state is composed by:
counter: updated when the increment action is emitted. This action accepts a payload (number) that is the value to add to the current counter.random : updated when the random action is emitted and has no payloadWe may think to update our type to the following:
type AppActions = {
type: 'increment' | 'decrement' | 'random';
payload?: number; // ? means "not required"
}
However, since we have used the question mark ?, the payload is optional, and we'll never know if it's really required or not when we'll dispatch an action.
Furthermore, things get even worse if we add a new action that required a string as payload.
What's the solution?
We can create a different type for each action and use the Union type again:
type Increment = { type: 'increment'; payload: number };
type Random = { type: 'random' };
type AppActions = Increment | Random;
The reducer is a simple function that is automatically invoked each time you dispatch a new action. This function always receives two parameters, the current state and the dispatched action, and returns the new state.
As you can see in the script below, the state is typed as AppState and the action as AppActions:
function appReducer(state: AppState, action: AppActions) {
switch (action.type) {
case 'increment':
return { ...state, counter: state.counter + action.payload };
case 'random':
return { ...state, random: Math.random() };
default:
return state;
}
}
Since our code is strongly typed you cannot use any string in the case statement, but increment and random are the only accepted values.
The switch statement and TypeScript Guards allow you to narrowing types.
In fact, the action.payload is of type number when the action is increment, while there is no payload if the action is random.
Move your mouse over the payload property in your favorite editor to check the payload type:
You can also create a new action with a string as payload and check if it works.
That's all.
You can now safely use the useReducer hook in your components and custom hooks.
const [state, dispatch] = useReducer(appReducer, { counter: 0, random: 0 });
And now you can dispatch the actions:
<button onClick={() => dispatch({ type: 'increment', payload: 10 })}>
<button onClick={() => dispatch({ type: 'random' })}>
Since our code is strongly typed you cannot pass any string anymore to the type property but increment and random are the only accepted values.
Furthermore, the increment action requires a payload of type number while the random action does not.
Here an example of a React component that uses the useReducer hook, dispatch actions and display the state:
App.tsxexport default function App() {
const [state, dispatch] = useReducer(appReducer, { counter: 0, random: 0 });
return (
<div>
<button onClick={() => dispatch({ type: 'increment', payload: 10 })}>
"Increment" Action
</button>
<button onClick={() => dispatch({ type: 'random' })}>
"Random" Action
</button>
<div>{state.counter}</div>
<div>{state.random}</div>
</div>
);
}
And of course you can also pass the state to children by drilling props, composition and Context:
<Child1 value={state.counter} />
<Child2 value={state.random} />
The completed example is available on StackBlitz
For simplicity I wrote all the code in App.tsx but, of course, you can (you should! 😅) split it in several files
I have also recorded a video tutorial about this topic available on my YouTube Channel:
