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 accordinglyuseReducer
The 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.tsx
export 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: