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.
Many authors have written a lot of interesting articles about this topic and I suggest you to read the Kent C. Dodds post "Should I useState or useReducer? " to get more info about it. You can also find a lot of info in the React Beta Documentation too.
However these tutorials are often written by using React and JavaScript so I wrote this post as introduction for another article about useReducer
and TypeScript
:
React 18.x & TypeScript | How to safely type the useReducer hook
useState
hooksuseState
onlyuseReducer
useState
hooksLet's imagine that we have to create a simple React component that needs to fetch data from a REST API, show a loading message and display an error if the HTTP request fails.
Furthermore we also want to remove an user from the list, always handling errors and loading messages.
The first example uses 3 different useState
to handle all the info we need to control the User Interface :
users
(Array): store all data fetched from a REST API (a list of users);pending
(Boolean): used to display a loading message. This state is set to true
when a new request is done and to false
when the request completes or fails;error
(Boolean): used to display an error message. It's true
when the request fails, otherwise must be false
.There are better solutions to fetch and handle data in React application. You can use Tantask (aka React Query), the latest version of React Router and many other techniques.
Anyway in this article I would like us to focus on differences between useState
and useReducer
Here the full example: read the comments I wrote into the snippet to understand how it works:
import axios from 'axios';
import React, { useEffect, useState } from 'react';
export const Demo1MultipleUseState = () => {
// state initialization
const [users, setUsers] = useState([]);
const [pending, setPending] = useState(false);
const [error, setError] = useState(false);
// when the component is mount
useEffect(() => {
// set the pending state
setPending(true);
// fetch data from a REST API
axios
.get('https://jsonplaceholder.typicode.com/users')
.then((res) => {
// success:
setUsers(res.data); // save the response in "users" state
setPending(false); // remove pending message
setError(false); // remove any previous errors
})
.catch((e) => {
setError(true); // set error
setPending(false); // remove pending
});
}, []);
function deleteHandler(id) {
setPending(true);
// remove the element invoking the endpoint
axios
.delete(`https://jsonplaceholder.typicode.com/users/${id}`)
.then((res) => {
// remove the element from the array
setUsers((s) => s.filter((u) => u.id !== id));
setError(false);
setPending(false);
})
.catch((e) => {
setError(true);
setPending(false);
});
}
return (
<div>
<h1>1. Multiple useState Demo</h1>
{pending && <div>loading...</div>}
{error && <div>some errors</div>}
<ul>
{users.map((u) => (
<li key={u.id}>
{u.name}
<button onClick={() => deleteHandler(u.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
};
The previous snippet would have generated multiple component renders before React 18 since we have set multiple useState
at the same time after an async operation.
But that's not a problem anymore since now multiple useState
operations are batched.
useState
onlyThe previous snippet works fine but the code becomes less and less readable as the number of useHooks
increases and are related each other.
So we can replace all states with just one useState
by using an object so structured:
{
users: [],
pending: false,
error: false
};
Now we can update the snippet as shown below. Read the comment for more info:
import axios from 'axios';
import React, { useEffect, useState } from 'react';
// NEW: state initialization
const initialState = { users: [], pending: false, error: false };
export const Demo2UniqueUseState = () => {
// UPDATE: we have just one useState now
const [data, setData] = useState(initialState);
useEffect(() => {
// UPDATE: set "pending" value
setData((s) => ({ ...s, pending: true }));
axios
.get('https://jsonplaceholder.typicode.com/users')
.then((res) => {
// UPDATE: now we use just one useState to update all properties
// to populate "users" and set both, "error" and "pending"
setData((s) => ({
users: s.users.filter((u) => u.id !== id),
error: false,
pending: false,
}));
})
.catch((e) => {
// UPDATE: update error and pending properties
setData((s) => ({ ...s, error: true, pending: false }));
});
}, []);
function deleteHandler(id) {
setData((s) => ({ ...s, pending: true }));
axios
.delete(`https://jsonplaceholder.typicode.com/users/${id}`)
.then((res) => {
setData((s) => ({
...s,
users: s.filter((u) => u.id !== id),
pending: false,
}));
})
.catch((e) => {
setData((s) => ({ ...s, error: true, pending: false }));
});
}
return (
<div>
<h1>2. Unique useState Demo</h1>
{data.pending && <div>loading...</div>}
{data.error && <div>some errors</div>}
<ul>
{data.users.map((u) => (
<li key={u.id}>
{u.name}
<button onClick={() => deleteHandler(u.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
};
What is this syntax?
✅ { ...s, pending: true }
The three dots ...
is known as spread operator
and it's used to shallow clone or merge all data from an object or an array.
It's very useful in this scenario (or when we need to update just one or few object's props) since I can avoid to manually set all properties to their previous values:
❌ { users: state.users error: state.error, pending: true }
Why do I pass a function to useState
:
✅ setData((s) => ({ ...s, pending: true }));
instead to simply use the state reference?
❌ setData({ ...data, pending: true });
The useState
hook returns a function that receives the current state as parameter.
Although it's not really necessary in this example, you should always use this approach when your new state depends from the previous one in order to avoid inconsistent or outdated values.
useReducer
The useReducer
hook allow you to create a function that defines how the state should be updated: this function is known as reducer
.
You've probably heard of Redux before, that is a global state manager for React applications, often used in enterprise projects.
Redux is a 3rd party library, but we may also define it as a pattern, based on the concept of reducers. So if you learn how to use the useReducer
hook you'll also know one of its fundamental concepts.
The useReducer
hook accepts three parameters:
const [state, dispatch] = useReducer(reducerFn, initialState);
It always returns the current state
and a dispatch
function that will be useful to notify the reducer when the state should be updated:
dispatch('increment')
dispatch('decrement')
dispatch('doSomething')
Although an action can be a simple string
, by convention it should be an object with a type
property (the action identifier) and a payload
(that contains optional parameters you can send with the action):
dispatch({ type: 'increment', payload: 10 }) // increment by 10
dispatch({ type: 'decrement', payload: 5 }) // decrement by 5
dispatch({ type: 'doSomething', payload 'anything' })
useReducer
works:dispatch
is invokeddispatch
action
object ({type: ..., payload: ...}
)type
and its payload
0
(zero):const [state, dispatch] = useReducer(myReducer, 0);
10
dispatch({ type: 'increment', payload: 10 })
function myReducer(state, action) {
switch (action.type) {
case 'increment':
return state + 1;
case 'decrement':
return state - 1;
}
// we always must return the current state in case we dispatch
// an action that is not handled by the reducer. Otherwise you'll lose the state value.
return state;
}
Finally we can migrate our previous code to use the useReducer
hook:
import axios from 'axios';
import React, { useEffect, useReducer } from 'react';
// state initialization
const initialState = { users: [], pending: false, error: false };
// NEW: the reducer function
function demoReducer(state, action) {
// check the action type
switch (action.type) {
case 'setPending':
// set the pending state to true
return { ...state, pending: true };
case 'setError':
// set the error state to true and remove pending
return { ...state, error: true, pending: false };
case 'loadUsers':
// store the payload into the state and remove pending
return { ...state, users: action.payload, pending: false };
case 'deleteUser':
// remove the element that has the ID we have sent as payload from the array
return {
users: state.users.filter((u) => u.id !== action.payload),
error: false,
pending: false,
};
}
return state;
}
export const Demo3UseReducer = () => {
// NEW: set the reducer and initial state
const [data, dispatch] = useReducer(demoReducer, initialState);
useEffect(() => {
// NEW: dispatch the pending action
dispatch({ type: 'setPending' });
axios
.get('https://jsonplaceholder.typicode.com/users')
// NEW: dispatch load users to store all received data into the state
.then((res) => dispatch({ type: 'loadUsers', payload: res.data }))
// NEW: dispatch the error action
.catch(() => dispatch({ type: 'setError' }));
}, []);
function deleteHandler(id) {
dispatch({ type: 'setPending' });
axios
.delete(`https://jsonplaceholder.typicode.com/users/${id}`)
.then(() => dispatch({ type: 'deleteUser', payload: id }))
.catch(() => dispatch({ type: 'setError' }));
}
return (
<div>
<h1>3. Unique useState Demo</h1>
{data.pending && <div>loading...</div>}
{data.error && <div>some errors</div>}
<ul>
{data.users.map((u) => (
<li key={u.id}>
{u.name}
<button onClick={() => deleteHandler(u.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
};
As I said, I have created this article as introduction to the next one:
React 18.x & TypeScript | How to safely type the useReducer hook
I really highly recommend to read it if you're using TypeScript!