We have seen that useState is the fundamental mechanism for reactivity in React, ideal for independent and simple values.
However, when managing interdependent states or complex transitions where a single action must mutate multiple variables simultaneously, the code tends to become difficult to maintain.
For complex state logic scenarios, React provides the useReducer hook.
For example, imagine a music player. When hitting “Next song,” many things have to happen at once:
- Increment the track index.
- Reset the playback time to 0.
- Set the playback state to
true. - Update the interface metadata.
If we do this with useState, we will have four set... calls scattered throughout the code. If there is more than one place to play, we would also have everything duplicated or triplicated. This is where useReducer fits.
When to use which?
useState: For independent, primitive values (numbers, booleans) or simple forms. This is what you will use 90% of the time.
useReducer: When the next state depends heavily on the previous one, or you have complex logic (multiple variables, nested objects, etc.)
useReducer Syntax
The useReducer hook is a React function that allows managing a component’s state using a reducer pattern.
This pattern is especially useful for handling complex states or when the state depends on previous actions.
The syntax of the useReducer hook is as follows:
const [state, dispatch] = useReducer(reducer, initialState);
As we see, it receives two arguments:
- Reducer: A function that takes the current state and an action, and returns a new state.
- Initial State: The initial value of the state.
On the other hand, it returns an array with two elements:
- Current State: The current value of the state.
- Dispatch: A function that allows sending actions to the reducer to update the state.
Structure of a Reducer
We have been saying all along that we are going to work with the Reducer pattern. But what is a reducer? It is a pure JavaScript function that:
- Receives the current state and an action
- Returns the new state.
That is, a syntax like this:
function reducer(state, action) {
switch (action.type) {
case 'ACTION_1':
// Returns a new state based on ACTION_1
return newState;
case 'ACTION_2':
// Returns a new state based on ACTION_2
return newState;
default:
// Returns the current state if the action is not recognized
return state;
}
}
By convention, Actions are objects with two properties:
// Standard action
{
type: 'UPDATE_EMAIL',
payload: '[email protected]'
}
type: A string in uppercase that describes what happened (INCREMENT,DELETE_USER,FETCH_START).payload(optional): The data needed to perform the action (the ID to delete, the user object to save, etc.).
Example
Quite a bit of crazy terminology? Don’t worry, it’s normal. Let’s try to make it clearer with an example.
It is a pure JavaScript function. Receives the current state and an action, and returns the new state.
// reducer.js
function taskReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, { id: Date.now(), text: action.payload }];
case 'DELETE':
return state.filter(task => task.id !== action.payload);
case 'CLEAR':
return [];
default:
return state;
}
}
import { useReducer } from 'react';
export default function TaskList() {
// Initialize the hook
const [tasks, dispatch] = useReducer(taskReducer, []);
const handleAdd = () => {
// SEND AN ACTION (We do not modify the state directly)
dispatch({
type: 'ADD',
payload: 'New task'
});
};
return (
<div>
<button onClick={handleAdd}>Add Task</button>
<ul>
{tasks.map(t => (
<li key={t.id}>
{t.text}
<button onClick={() => dispatch({ type: 'DELETE', payload: t.id })}>
X
</button>
</li>
))}
</ul>
</div>
);
}
Why is this better than useState?
At first glance, it seems like much more code to do the same thing. And it’s true: for a simple counter, useReducer is overkill.
Decoupling
The logic of how the state is updated is outside the component. You could move the taskReducer function to another file and test it in isolation without rendering React.
Clear Intent
When reading dispatch({ type: 'LOGIN_SUCCESS' }), you understand exactly what happened in the application. When reading setIsAuthenticated(true); setUser(data); setLoading(false);, you have to decipher what that set of changes means.
Debugging
If the state is incorrect, you know the error is in the reducer. With useState, the error could be in any of the 20 functions that call setState.
