
Updated on
Elevate Your React State Management: When to Choose useReducer Over useState
Managing state is a fundamental aspect of building robust React applications. While the useState
hook is perfect for handling simple state variables, complex state logic can quickly become cumbersome and error-prone, like when managing multiple useState
instances (imagine more than 6).
This is where the useReducer
hook comes in, offering a more structured and maintainable approach to state management.
The Limitations of useState
in Complex Scenarios
Consider a scenario where you're building a signup form with multiple fields and validation requirements. Unless you are using a form library to handle complex form state like React Hook Form (I highly recommend it, it's been a life changer for me), using useState
to manage each field and its associated state might look like this:
import { useState } from 'react';
function SignupForm() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
// Simulate API call
await new Promise((res) => setTimeout(res, 1000));
alert('Signup successful!');
} catch (err) {
setError('Signup failed!');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
{error && <p>{error}</p>}
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
);
}
In this example, managing multiple useState
hooks for each piece of state can lead to a cluttered and hard-to-maintain component, especially as the form grows in complexity.
Embracing useReducer
for Complex State Management
Refactoring the above form to use useReducer
can lead to a more organized and scalable solution. Here's how you can implement it:
import { useReducer } from 'react';
const initialState = {
username: '',
email: '',
password: '',
error: null,
isSubmitting: false,
};
function reducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
[action.payload.field]: action.payload.value,
};
case 'SUBMIT_START':
return {
...state,
isSubmitting: true,
error: null,
};
case 'SUBMIT_SUCCESS':
return {
...state,
isSubmitting: false,
};
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
error: action.payload.error,
};
default:
return state;
}
}
function SignupForm() {
const [state, dispatch] = useReducer(reducer, initialState);
const { username, email, password, error, isSubmitting } = state;
const handleChange = (e) => {
dispatch({
type: 'SET_FIELD',
payload: {
field: e.target.name,
value: e.target.value,
},
});
};
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await new Promise((res) => setTimeout(res, 1000));
dispatch({ type: 'SUBMIT_SUCCESS' });
alert('Signup successful!');
} catch {
dispatch({ type: 'SUBMIT_ERROR', payload: { error: 'Signup failed!' } });
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" value={username} onChange={handleChange} placeholder="Username" />
<input name="email" value={email} onChange={handleChange} placeholder="Email" />
<input type="password" name="password" value={password} onChange={handleChange} placeholder="Password" />
{error && <p>{error}</p>}
<button type="submit" disabled={isSubmitting}>Submit</button>
</form>
);
}
By centralizing state management in a reducer function, the component becomes cleaner and easier to maintain. Each action clearly defines the intent, making the state transitions predictable and debuggable.
Now let's look at this new solution and analyze what we've done step-by-step:
1. Create an initial state
First of all, we removed all the component states defined with useState
, and moved them to one central state object called initialState
. That part is easy enough.
const initialState = {
username: '',
email: '',
password: '',
error: null,
isSubmitting: false,
};
Keep in mind that it is outside of our component definition.
2. Define your reducer function
This is where you put all of your state logic. A reducer is just a function that takes in the current state and an action, and returns the new state.
If you didn't understand the part above, it's ok, I didn't get it at first either, so let's break it further down.
1. State
This is the state object with its initial value, before any updates to it:
const initialState = {
username: '',
email: '',
password: '',
error: null,
isSubmitting: false,
};
2. Action
This is an object describing what happened. It must have a type
property.
For example:
{ type: "SUMBIT_START" }
It can also have another optional property payload
holding the specific value to update the state with.
{
type: "SET_FIELD",
payload: {
field: "username",
value: "Marouane",
}
}
3. Return value
the function must return a new state object (not mutate the old one).
Here's the final output of the reducer function:
function reducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
[action.payload.field]: action.payload.value,
};
case 'SUBMIT_START':
return {
...state,
isSubmitting: true,
error: null,
};
case 'SUBMIT_SUCCESS':
return {
...state,
isSubmitting: false,
};
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
error: action.payload.error,
};
default:
return state;
}
}
Note that depending on the action type, we are updating the different properties of our state, and using payload values to do that, though not always.
For example, the SUBMIT_START
action type only describes the start of submitting state, so we only update isSubmitting
field to true
, while the SET_FIELD
action type means we are updating a form field value, for example the username field, and for that we need the actual field and its value to update the field with, which we carry in the action payload object.
Another thing to note is that we are not mutating the state object, but returning a new one (via object destructuring), so React can reactively rerender our component with the new change.
4. Replace useState
with useReducer
Now, Instead of this:
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
We do this:
const [state, dispatch] = useReducer(reducer, initialState);
const { username, email, password, error, isSubmitting } = state;
Amazing! we just replaced 5 useState
calls in 5 different lines with just one line of code thanks to useReducer
(the destructuring of state
object in optional).
Now we can access our state with the state
object, and make changes to it with the dispatch
function.
The dispatch
function accepts the action
object we mentioned earlier like this:
dispatch({
type: 'SET_FIELD',
payload: {
field: e.target.name,
value: e.target.value,
},
});
It is our alternative to setState
function to update the state. Instead of setUsername("new username")
, we can just call dispatch
with the above format.
As we can see, while useReducer
is definitely more complex than useState
, it makes our code way more manageable and offers a new, scalable way to deal with our component state as it gets more complicated.
Important Notice About Action Types
While naming our action types directly in our reducer function will work just fine, you should avoid using direct strings to avoid typos and silly bugs that can be caused by mistyping the action type.
For example, this code below will not work because of the missing E
in SET_FIELD
.
dispatch({
type: 'SET_FILD',
payload: {
field: e.target.name,
value: e.target.value,
},
});
This stupid typo can derail your project's progress for days or weeks even (ask me how I know...).
Instead, what you should do is create a new actions types object (or enum, if you're using TypeScript), and define your types there, like this:
const actionTypes = Object.freeze({
SET_FIELD: 'SET_FIELD',
SUBMIT_START: 'SUBMIT_START',
SUBMIT_SUCCESS: 'SUBMIT_SUCCESS',
SUBMIT_ERROR: 'SUBMIT_ERROR',
})
Object.freeze()
method freezes an object, meaning it prevents extending it, and you can't add new properties or delete existing ones, or editing them. It's perfect for our case.
Now, use actionTypes
inside your dispatch function like this:
dispatch({
type: actionTypes.SET_FIELD,
payload: {
field: e.target.name,
value: e.target.value,
},
});
And in the reducer function:
function reducer(state, action) {
switch (action.type) {
case actionTypes.SET_FIELD:
return {
...state,
[action.payload.field]: action.payload.value,
};
case actionTypes.SUBMIT_START:
return {
...state,
isSubmitting: true,
error: null,
};
case actionTypes.SUBMIT_SUCCESS:
return {
...state,
isSubmitting: false,
};
case actionTypes.SUBMIT_ERROR:
return {
...state,
isSubmitting: false,
error: action.payload.error,
};
default:
return state;
}
}
It's much cleaner now, and error-proof. It's save you hours of debugging, so you should definitely use this approach (I learned my lesson the hard way).
BONUS: useReducer
with TypeScript
To add type safety to our useReducer
hook, we can take advantage of TypeScript's discriminated unions to add robust types to our action
object.
First, we can replaced our actionTypes
object with an enum:
enum ActionTypes {
SET_FIELD = 'SET_FIELD',
SUBMIT_START = 'SUBMIT_START',
SUBMIT_SUCCESS = 'SUBMIT_SUCCESS',
SUBMIT_ERROR = 'SUBMIT_ERROR',
}
Here is our type for action
:
type Action = {
type: "ActionTypes.SET_FIELD";
payload: {
field: "username" | "email" | "password";
value: string;
}
}
| {
type: "ActionTypes.SUBMIT_START";
}
| {
type: "ActionTypes.SUBMIT_SUCCESS";
}
| {
type: "ActionTypes.SUBMIT_ERROR";
payload: {
error: string;
}
}
We took advantage of TypeScript's discriminated unions to create a robust type for our action
object. I wrote an extensive article about discriminated unions if you want to take a look, and I highly recommend that you do so.
And in the end, we have our simple State
type:
type State = {
username: string;
email: string;
password: string;
isSubmitting: boolean;
error: string | null;
}
And now, let's annotate our reducer funciton with the appropriate types:
function reducer(state: State, action: Action): State {
switch (action.type) {
case ActionTypes.SET_FIELD:
return {
...state,
[action.payload.field]: action.payload.value,
};
case ActionTypes.SUBMIT_START:
return {
...state,
isSubmitting: true,
error: null,
};
case ActionTypes.SUBMIT_SUCCESS:
return {
...state,
isSubmitting: false,
};
case ActionTypes.SUBMIT_ERROR:
return {
...state,
isSubmitting: false,
error: action.payload.error,
};
default:
return state;
}
}
And congratulations! Now you have type safety in your application. You can now use VSCode's autocompletion to write your dispatch functions without worrying that you'll dispatch a non-existing action type because TypeScript will scream in your face to let you know about it.
When to Choose useReducer
Over useState
-
Complex State Logic: When state transitions involve multiple sub-values or the next state depends on the previous one,
useReducer
provides a structured approach to handle such complexity. -
Related State Updates: If multiple state variables are related and should be updated together, managing them with
useReducer
ensures consistency and reduces the risk of bugs. -
Scalability: As your component grows, having a centralized reducer function makes it easier to manage and scale the state logic without cluttering the component with multiple
useState
calls.
In conclusion, while useState
is suitable for simple state management, embracing useReducer
for complex state scenarios can lead to more maintainable and scalable React components. By thoughtfully choosing the appropriate hook for your state management needs, you can enhance the clarity and robustness of your applications.