This article was last updated on November 8, 2024 to include lazy initialization, error handling techniques, and default selection strategies in advanced useReducer implementations.
Introduction
React useReducer()
hook is a state hook used often as a versatile alternative to useState()
. It helps aggregate multiple states of a component in one place, particularly in scenarios that involve the state's changes at multiple nesting levels, and originating from multiple action types and sources. useReducer()
gives access to data and actions defined in a React reducer. It exposes the state
for a component to consume, and a dispatch()
function to invoke actions that alters the state
.
In this post, we cover the basics of implementing consolidated state management with a reducer and the useReducer()
hook, along with some essential good practices associated with writing efficient reducer functions.
We first spend time to understand what essentially makes a React reducer, how it works and when to use one. That it consists of a aggregated state, a set of action type
s and a reducer function that defines and implements those action types. Then, with examples from a small demo Posts app, we explore how useReducer()
is used to access a reducer posts
state and its dispatch()
function.
We demonstrate how to consume reducer data (stored as posts
) and present them in JSX. While doing so, we also cover how to invoke actions with dispatch()
by passing desired types to it (like
and unlike
). We discuss the importance of a reducer function and acknowledge the painstaking care necessary while writing one. We analyze relevant code snippets to show some good practices involved in efficiently composing React reducer functions that is used by a useReducer()
hook. We spare time to understand the significance of initial reducer state and examine examples of how to pass one to useReducer()
.
In the later half, we expand our example to demonstrate how useReducer()
enables state updates on multiple nesting levels. We add create
and delete
action types to the topmost level of posts
that allow creating and deleting a post respectively. We elucidate with an example how to use a state initializer function for enhancing performance of a reducer. Towards the end, discuss the benefits of using useReducer()
, how reducers are vital in Redux and how useReducer()
and Redux compare.
Steps we follow in this post are as follows:
- What is React useReducer ?
- React State Manipulation with useReducer: A Demo Posts App
- React useReducer: Action on Multiple Levels
- React useReducer: Benefits and Comparison
- Error Handling in Reducers
What is React useReducer ?
useReducer()
is a React state hook which promotes an "action" based state management paradigm that involves component state consolidated or reduced in a reducer object. The state is manipulated by dispatching different types of actions, contrary to using a single setter function typical of useState()
. Using actions to change state of reducer makes it a versatile tool -- as doing so allows a myriad of actions to defined and performed on the aggregated state.
React Reducers with useReducer
Reducers get their name because they accummulate data into one piece in a way similar to how the JavaScript Array.prototype.reduce()
method does. Reducers make the heart of dedicated global state management solutions like Redux and RTK Query (Redux Toolkit Query).
Reducers in plain React, though, are used for smaller scale local state management in components, as opposed to managing global state involving multiple entities and features of the app.
For setting up and handling plain React reducers, we use the useReducer()
hook. It is important to note that in many scenarios, useState()
can be used to manage trivial, one-off local states. Only when complexity of local state becomes unmanageable with useState()
, yet tied together around one application entity or closely related actions, we think of using a reducer in conjunction with the useReducer()
hook.
useReducer()
]Typical use cases of React useReducer()
hook arise when we need more types of change in a component state -- such as types of like
s in a post: like
, unlike
, angry
, happy
, love
, celebrate
, etc.
In general, we can use useReducer()
in the following scenarios:
- When we initialize multiple states using
useState()
, but they seem to be related and can be grouped or aggregated into one. - We have multiple types of actions for altering the state.
- We have multiple types of events that trigger those actions.
- We have multiple sources of those actions, such as from different components and different levels of the component hierarchy.
- We have actions on multiple nesting levels of the state object.
- We need to reuse and reinitialize the reducer in different components.
- We can reuse the reducer for similar application entities.
How React useReducer Works
The useReducer()
hook in React accepts a reducer function and an initial state. It builds a reducer from these arguments and exposes the state
and a dispatch()
function for the component to use. For example, like this:
// Inside a component
const [state, dispatch] = useReducer(postsReducer, initialPosts);
// Consume `state` in JSX
// Invoke `dispatch()` with action type passed in button clicks
The dispatch()
function is used to dispatch action types invoked from an event handler, such as on an onClick
event. dispatch()
typically takes a type
prop in an argument object passed to it. More details later in this section.
Reducer Functions in React
The specifics of a dispatched action type, its implementation and return values are declared in a React reducer function. A reducer function generally includes all actions related to a particular type of an application entity. For example, the following postsReducer
function defines a couple of action type
s for a post
entity:
export function postsReducer(posts, action) {
const { type, payload } = action;
const { id } = payload;
switch (type) {
case "like":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes + 1,
};
} else {
return p;
}
});
case "unlike":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes <= 0 ? p?.likes : p?.likes - 1,
};
} else {
return p;
}
});
default:
return posts;
}
}
The two actions we have are like
and unlike
, which are represented as two cases of the switch
statement.
Notice, the reducer function takes in the posts
state, along with an action
as arguments. The passed in posts
state represents the current state. It works on the current state according to the data sent with the action
argument and sends out a new posts
version. In other words, it acts directly on the state and returns an updated version.
Notice also that, the action
consists of a type
property and any specifics that describe the action: such as a payload
with id
and related data.
The initial state of this reducer must be present before hand. With useReducer()
, state is initialized with the initialState
or an initializer
function as argument. More on this here.
React State Manipulation with useReducer: A Demo Posts App
In this section, we delve into the implementation of reducers and actions with React useReducer()
hook in a demo Posts app. The app allows like
and unlike
actions on a post.
Starter Files
The demo app is available in the repository here. Please feel free to get a copy of the main
branch and run it locally by following these steps:
- Clone this repository to a directory of your choice.
- Navigate to the app folder and run the server:
cd hooks-user
npm install
npm run start
This will get the app running on http://localhost:3000
.
Please feel free to play around with the number of likes on each post, by liking and unliking them. Navigate around the code in your editor and try to make sense of what's going on.
The <Posts />
Component
The main action is happening in the <Posts />
component. It looks like below:
Show `` component
import React, { useReducer } from "react";
import { postsReducer, initialPosts } from "../reducers/postsReducer";
import {
HandThumbDownIcon,
HandThumbUpIcon,
HeartIcon,
} from "@heroicons/react/24/outline";
export default function Posts() {
const [posts, dispatch] = useReducer(postsReducer, initialPosts);
return (
<div className="container mx-auto my-16 rounded-lg bg-gray-100">
<div className="ml-8 w-3/5 bg-inherit pt-8">
<h2 className="title">All Posts</h2>
{posts?.map((post) => {
return (
<div class="card">
<div class="p-6">
<div className="px-2">
<div className="flex items-center justify-between">
<div>
<h5 class="mb-1 text-xl font-medium leading-tight">
{post?.title}
</h5>
<h6 class="subtitle">{post?.subtitle}</h6>
</div>
<div className="relative flex items-center justify-center">
<HeartIcon
height={32}
width={32}
className="fill-red-500 text-red-500"
/>
<span className="absolute text-xs font-normal text-gray-50">
{post?.likes}
</span>
</div>
</div>
</div>
<p class="mb-4 bg-gray-50 p-2 text-base leading-normal">
{post?.content}
</p>
<div className="flex gap-4">
<button
type="button"
class="btn-like"
onClick={() =>
dispatch({ type: "unlike", payload: { id: post?.id } })
}
>
<HandThumbDownIcon
height={20}
width={20}
className="text-red-400"
/>
</button>
<button
type="button"
class="btn-like"
onClick={() =>
dispatch({ type: "like", payload: { id: post?.id } })
}
>
<HandThumbUpIcon
height={20}
width={20}
className="text-blue-700"
/>
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
The JSX presents a list of post items in the posts
array, by mapping each item into a card. Typical React stuff. Notice inside the <button />
elements, we are invoking a dispatch()
function to handle the onClick
event.
Below, we explain useReducer()
related concepts that underly posts
and dispatch
with relevant code snippets.
React useReducer: How to Initialize an Action Based Reducer
It's really easy to initialize a plain React reducer. Inside <Posts />
, we initialized a reducer with the useReducer()
hook:
const [posts, dispatch] = useReducer(postsReducer, initialPosts);
The useReducer()
hook must be passed a reducer function and an initial state. Here, we pass postsReducer
as a reducer function and initialPosts
as initial posts
state.
With this, we make sure that for each change of state invoked, we'd receive the posts
reducer state and the dispatch
function. In other words, the useReducer()
hook creates a reducer from the reducer function and an initial state. And it returns an updated state for us to present in our component. With the dispatch
function, it particularly gives access to a set of actions and their implementations -- which we can execute to effectuate a desired change.
Actions, their types and implementations need to be defined ahead of time in a reducer function (postsReducer
here), which we are going to see next.
React useReducer: Anatomy of a Reducer Function
The reducer function we have for posts
state here is postsReducer
. It looks like this:
Show `postsReducer` code
export const initialPosts = [
{
id: 3,
title: "What's a Reducer Function ?",
subtitle: "A function that defines the actions possible on a reducer state",
content:
"A reducer function declares what's possible on a reducer state. It defines all possible actions that can be performed on the reducer state and what to change when a particular action is dispatched.",
likes: 5,
},
{
id: 2,
title: "What is a Reducer ?",
subtitle: "State that acts as a single source of truth",
content:
"A reducer is composed of a state and a set of actions that can be dispatched to alter the state. It can be refactored and stored outside of a component. And can be used as a source of truth for multiple use cases.",
likes: 4,
},
{
id: 1,
title: "The React useReducer Hook",
subtitle: "State management with actions, instead of setters",
content: `The useReducer hook is a more versatile alternative to useState. It introduces an "actions" based paradigm to state management - in contrast to setters that exists in useState. Actions are defined and dispatched to effectuate changes in a reducer state.`,
likes: 2,
},
];
export function postsReducer(posts, action) {
const { type, payload } = action;
const { id } = payload;
switch (type) {
case "like":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes + 1,
};
} else {
return p;
}
});
case "unlike":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes <= 0 ? p?.likes : p?.likes - 1,
};
} else {
return p;
}
});
default:
return posts;
}
}
A React Reducer Function Operates on State with Action Params
Notice the posts
and action
params taken by the postsReducer
function. posts
itself is the current reducer state. And the internals of postsReducer
function case by case directly acts on the reducer state. Like what happens here:
export function postsReducer(posts, action) {
const { type, payload } = action;
const { id } = payload;
switch (type) {
case "like":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes + 1,
};
} else {
return p;
}
});
case "unlike":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes <= 0 ? p?.likes : p?.likes - 1,
};
} else {
return p;
}
});
default:
return posts;
}
}
The reducer function acts on the current state based on the action
parameters it would receive with a dispatch()
invocation from inside a component.
React Reducer Function Action Types
The action params of a reducer, by convention, has a type
property. The value of type
is essentially a descriptive name for the action. It is used for identifying the related dispatched action.
In the reducer function, we have to define types for each possible action. And then implement changes to the state according to their needs. For example, we have two types of actions we want to perform on posts
state. like
and unlike
:
switch (type) {
case "like":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes + 1,
};
} else {
return p;
}
});
case "unlike":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes <= 0 ? p?.likes : p?.likes - 1,
};
} else {
return p;
}
});
default:
return posts;
}
like
increases the current likes
value of a post
object by +1
. And unlike
decreases by 1
. Here, we are mapping over the items inside posts
to find the post
with an id of id
, and then bringing necessary changes to its likes
. Apparently, the implementation of action types vary according to what they represent. These actions are availed to a host component by useReducer()
via its exposed dispatch()
function.
React Reducer Function: How to Handle Action Based State Change
Notice, how we are handling changes related to each type. Most often, we accept related data as action parameters. As in our example, it is common to accept data in a payload
property of the action. The id
passed to payload
property above is used to find the post with that id
:
const { type, payload } = action;
const { id } = payload;
As you can notice, we need to discretely handle each action type. Here we are using a switch
statement to handle each state change.
switch
Statements in a Reducer Function]It is cleaner to use switch
statements for handling action types in a React reducer function. However, we can also use if/else
and if/else if/else
statements when they deem better options.
For each state change, we are also using array destructuring with the spread operator ...
.
React Reducer Function: Importance of the Spread Operator
Notice we have destructured a post, p
in both of our action types. For example, in the like
action, we have iterated across posts
to find a p
post that matches its p?.id
to id
and then destructured it in order to alter its likes
property.
The use of spread operator makes React reducer functions very versatile in altering properties deep inside a reducer state, i.e., inside nested objects. Reducers employ the spread operator extensively to create new copies, as the current state must be immutable in order to properly derive the next state.
However, array and object destructuring is painstaking and cumbersome. And meticulous care must be exercised while updating properties in deeply nested reducer objects.
Immerjs is an npm
library that handles state updates brilliantly. It employs a proxy for efficiently handling state updates typical of nested reducers in React and Redux.
Immer's produce()
and draft()
APIs are central to defining and updating a reducer state. It implements immutability as one of its core principles. Feel free to explore its usage from the docs here.
React Reducer Function: Returning Action Values
While implementing action types, we need to make sure we return the necessary object at each level of traversal.
For example in the postsReducer
function above, we have returned at each level of traversal as needed:
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes + 1,
};
} else {
return p;
}
});
Without proper return
s, we run the risk of encountering unexpected consequences -- like a different state updates, undefined
or no update at all.
It is difficult to debug errors while composing a reducer function in React. We have to adhere to the common safety practices described in this section.
For more detailed explanations of these good practices, please check out this section of the useReducer()
docs.
React useReducer: Passing Initial State
The useReducer()
hook has to be passed an initial state in order for it to get a initial current value. The initial state is passed as the second argument to useReducer()
. In our example, the initialPosts
argument is an array of post
objects. It looks like this:
[
{
id: 3,
title: "What's a Reducer Function ?",
subtitle: "A function that defines the actions possible on a reducer state",
content:
"A reducer function declares what's possible on a reducer state. It defines all possible actions that can be performed on the reducer state and what to change when a particular action is dispatched.",
likes: 5,
},
{
id: 2,
title: "What is a Reducer ?",
subtitle: "State that acts as a single source of truth",
content:
"A reducer is composed of a state and a set of actions that can be dispatched to alter the state. It can be refactored and stored outside of a component. And can be used as a source of truth for multiple use cases.",
likes: 4,
},
{
id: 1,
title: "The React useReducer Hook",
subtitle: "State management with actions, instead of setters",
content: `The useReducer hook is a more versatile alternative to useState. It introduces an "actions" based paradigm to state management - in contrast to setters that exists in useState. Actions are defined and dispatched to effectuate changes in a reducer state.`,
likes: 2,
},
];
Once again, the initial state should directly produce the state itself, without being nested as property of any parent. Since, the reducer function performs its actions directly on it.
React useReducer: Action on Multiple Levels
The action types above alter the state inside a post
object. We can allocate action types for changes at other levels too. In this section, we add features of creating a post
with a create
type and deleting one with delete
type.
Let's update the postsReducer
function to below:
Show updated `postsReducer`
export const initialPosts = [
{
id: 3,
title: "What's a Reducer Function ?",
subtitle: "A function that defines the actions possible on a reducer state",
content:
"A reducer function declares what's possible on a reducer state. It defines all possible actions that can be performed on the reducer state and what to change when a particular action is dispatched.",
likes: 5,
},
{
id: 2,
title: "What is a Reducer ?",
subtitle: "State that acts as a single source of truth",
content:
"A reducer is composed of a state and a set of actions that can be dispatched to alter the state. It can be refactored and stored outside of a component. And can be used as a source of truth for multiple use cases.",
likes: 4,
},
{
id: 1,
title: "The React useReducer Hook",
subtitle: "State management with actions, instead of setters",
content: `The useReducer hook is a more versatile alternative to useState. It introduces an "actions" based paradigm to state management - in contrast to setters that exists in useState. Actions are defined and dispatched to effectuate changes in a reducer state.`,
likes: 2,
},
];
export function postsReducer(posts, action) {
const { type, payload } = action;
const { id } = payload;
switch (type) {
case "create":
return [payload, ...posts];
case "delete":
return posts?.filter((p) => p?.id !== id);
case "like":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes + 1,
};
} else {
return p;
}
});
case "unlike":
return posts.map((p) => {
if (p?.id === id) {
return {
...p,
likes: p?.likes <= 0 ? p?.likes : p?.likes - 1,
};
} else {
return p;
}
});
default:
return posts;
}
}
Notice, now the create
and delete
actions implement necessary changes on the topmost level -- at the array level, in order to add and filter out an item to/from the posts
state.
In <Posts />
component, we are going to add a form for creating a post. The form gathers a post
data from input fields and dispatches the create
action upon submission. The form is contained inside the <CreatePostForm />
component. It looks like below:
Show `CreatePostForm />` component code
import { useState } from "react";
export function CreatePostForm({ posts, dispatch }) {
const initialFormData = {
id: posts?.length + 1,
title: "",
subtitle: "",
content: "",
likes: 0,
};
const [postFormData, setPostFormData] = useState(initialFormData);
const handleChange = (e) => {
setPostFormData({
...postFormData,
[e.target.name]: e.target.value,
});
};
const handleSubmit = (e) => {
e.preventDefault();
dispatch({ type: "create", payload: postFormData });
setPostFormData(initialFormData);
};
return (
<div className="mx-auto">
<h2 className="title">Create Post</h2>
<form
className="rounded-lg bg-white p-6 shadow-md"
onSubmit={handleSubmit}
>
<div className="form-control">
<label htmlFor="title" className="form-label">
Title
</label>
<input
className="form-input"
name="title"
type="text"
value={postFormData?.title}
onChange={handleChange}
placeholder="Add title"
/>
</div>
<div className="form-control">
<label htmlFor="subtitle" className="form-label">
Subtitle
</label>
<input
className="form-input"
name="subtitle"
type="text"
value={postFormData.subtitle}
onChange={handleChange}
placeholder="Add subtitle"
/>
</div>
<div className="form-control">
<label htmlFor="content" className="form-label">
Content
</label>
<textarea
className="form-textarea"
name="content"
rows={7}
cols={15}
value={postFormData?.content}
onChange={handleChange}
placeholder="Add content"
></textarea>
</div>
<button className="btn-primary" type="submit">
Create
</button>
</form>
</div>
);
}
Notice, we pass the posts
array and the dispatch
function to <CreatePostForm />
and use them inside the JSX where necessary. A create
action is dispatched with form data when the form is submitted:
const handleSubmit = (e) => {
e.preventDefault();
dispatch({ type: "create", payload: postFormData });
setPostFormData(initialFormData);
};
For each post in <Posts />
, we'll add a delete
button that dispatches the delete
action type:
<button
class="btn-delete"
onClick={() => dispatch({ type: "delete", payload: { id: post?.id } })}
>
Delete
</button>
We'll add <CreatePostForm />
inside <Posts />
. So update the <Posts />
component to below:
Show updated `` component code
import React, { useReducer } from "react";
import { CreatePostForm } from "../components/create-post-form";
import { postsReducer, initialPosts } from "../reducers/postsReducer";
import {
HandThumbDownIcon,
HandThumbUpIcon,
HeartIcon,
} from "@heroicons/react/24/outline";
export default function Posts() {
const [posts, dispatch] = useReducer(postsReducer, initialPosts);
return (
<div className="container mx-auto my-16 rounded-lg bg-gray-100">
<div className="flex items-start justify-start p-8">
<div className="">
<CreatePostForm posts={posts} dispatch={dispatch} />
</div>
<div className="ml-8 w-3/5 bg-inherit">
<h2 className="title">All Posts</h2>
{posts?.map((post) => {
return (
<div class="card">
<div class="p-6">
<div className="px-2">
<div className="flex items-center justify-between">
<div>
<h5 class="mb-1 text-xl font-medium leading-tight">
{post?.title}
</h5>
<h6 class="subtitle">{post?.subtitle}</h6>
</div>
<div className="relative flex items-center justify-center">
<HeartIcon
height={32}
width={32}
className="fill-red-500 text-red-500"
/>
<span className="absolute text-xs font-normal text-gray-50">
{post?.likes}
</span>
</div>
</div>
</div>
<p class="mb-4 bg-gray-50 p-2 text-base leading-normal">
{post?.content}
</p>
<div className="flex items-center justify-between px-2">
<div className="">
<button
class="btn-delete"
onClick={() =>
dispatch({
type: "delete",
payload: { id: post?.id },
})
}
>
Delete
</button>
</div>
<div className="flex gap-4">
<button
type="button"
class="btn-like"
onClick={() =>
dispatch({
type: "unlike",
payload: { id: post?.id },
})
}
>
<HandThumbDownIcon
height={20}
width={20}
className="text-red-400"
/>
</button>
<button
type="button"
class="btn-like"
onClick={() =>
dispatch({ type: "like", payload: { id: post?.id } })
}
>
<HandThumbUpIcon
height={20}
width={20}
className="text-blue-700"
/>
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
With these changes, we should now be able to use the form fields to create a post and use the delete
button to delete one.
React useReducer: Using an Initializer Function
In the demo, we have passed the initial state of posts
as a pre-declared array of objects. However, in a real application, the initial state comes from an API typically with a fetch
call or any other third party library such as Axios or React Query.
So, we can expect the initial values to yield from an async
function. In such a scenario, it is possible to pass an initial state by invoking the async
function with necessary arguments. For example, like the getInitialPosts
function below:
const getInitialPosts = async (url) => getPosts(url);
const [posts, dispatch] = useReducer(
postsReducer,
await getInitialPosts("/posts"),
);
// Invoking is not ideal
However, invoking an initializer is suboptimal, since userReducer
's initial state gets evaluated only during the reducer's initialization. And it does not get re-evaluated on state updates and subsequent renders. In other words re-evaluation of state initializer function is ignored despite it being called every time the component re-renders.
So, using an invoked initializer function with useReducer()
wastes application resources.
We can instead pass the function itself as the third argument to useReducer()
, and any of its own arguments as the second argument to useReducer()
. Like so:
const [posts, dispatch] = useReducer(postsReducer, "/posts", getInitialPosts);
// Initializer function passed as third argument, without being invoked
// Any argument of initializer function passed as second argument to `useReducer()`
This way, the initializer function (getInitialPosts
here) passed its dependent arguments (/posts
url) on the reducer's initialization. The initializer function now only gets invoked during that time. Any subsequent call is prevented. This helps optimize the component.
Error Handling in Reducers
Error handling is by no means a trivial matter when working with useReducer in React; doing it properly will keep your application robust and provide good, descriptive feedback to your users. This section describes a number of ways to handle such errors in reducer functions, with the inclusion of multiple actions and complex states.
Why Handle Errors in Reducers?
By convention in React, a reducer manages the update of state based on the actions that have been dispatched from the component. Where such actions involve processing data or API interaction, there are going to be errors from unexpected action types, invalid data, or logical issues. Catching such error occurrences within the reducer will:
- Keep the application state stable and avoid breaking the updates.
- If something was wrong, give constructive feedback.
- Minimize debugging by catching errors closer to their cause.
Setup Error Handling in Reducers
Error handling in a reducer can be managed using techniques such as default cases, error states, and external utilities.
Default Case for Unhandled Actions
A simple way to catch unknown actions is by defining a default case in the reducer’s switch statement: for any unrecognized action type that’s been dispatched, the default case can either return the current state (ignoring the action) or set an error message in the state.
const initialState = {
posts: [],
error: null,
};
function postsReducer(state, action) {
const { type, payload } = action;
switch (type) {
case "ADD_POST":
return { ...state, posts: [...state.posts, payload], error: null };
case "DELETE_POST":
return {
...state,
posts: state.posts.filter((post) => post.id !== payload.id),
error: null,
};
case "LIKE_POST":
return {
...state,
posts: state.posts.map((post) =>
post.id === payload.id ? { ...post, likes: post.likes + 1 } : post,
),
error: null,
};
default:
console.error(`Unknown action type: ${type}`);
return { ...state, error: `Unknown action type: ${type}` };
}
}
If an unhandled action type is dispatched, the default triggers an error log in the console and stores a relevant error message in the state. This message can then be displayed to the user or used for debugging.
Storing Error State Within the Reducer
If your application is driven by API data, for instance, you can set fetch or process errors in your reducer state to manage error states and display them.
const initialState = {
posts: [],
error: null,
loading: false,
};
function postsReducer(state, action) {
const { type, payload } = action;
switch (type) {
case "FETCH_POSTS_REQUEST":
return { ...state, loading: true, error: null };
case "FETCH_POSTS_SUCCESS":
return { ...state, posts: payload, loading: false, error: null };
case "FETCH_POSTS_FAILURE":
return { ...state, loading: false, error: payload.error };
default:
return state;
}
}
In this approach, if an error occurs while fetching data, the FETCH_POSTS_FAILURE case is dispatched with an error message, setting error in the state. This method is especially useful for displaying error messages directly in the UI.
Error Handling with External Utilities
In the case of complex reducers with many action types, this is cleaner if you use a utility function to handle the errors—say, a wrapper function that logs or reports an error without cluttering the reducer itself.
function handleError(reducer) {
return function (state, action) {
try {
return reducer(state, action);
} catch (error) {
console.error("Reducer error:", error);
return { ...state, error: "Something went wrong. Try again." };
}
};
}
const wrappedPostsReducer = handleError(postsReducer);
Using handleError, errors in postsReducer are caught, logged, and an error message is set in the state. This code can be abstracted away and reused across different reducers.
Best Practices of Error Handling in Reducers
- Centralize Error Messages: Define error messages in one place, such as in a separate file or inside the state itself, for manageability and updating convenience.
- Log Errors in Development: console.error or other logging utilities will help catch errors during development, making debugging easier.
- Show User-Friendly Messages: Avoid displaying technical error details in the UI. Instead, show a generic message like “Something went wrong. Please try again.”
- Avoid Throwing Errors in Reducers: Reducers should be pure functions with no side effects. It’s best not to throw errors within them, but rather handle and log them gracefully.
By implementing these techniques, your reducer functions in React become more robust, user-friendly, and easier to maintain and debug over time.
React useReducer: Benefits and Comparison
In this section, we discuss the benefits of using useReducer()
hook and how it compares to Redux which also uses reducers under the hood.
Benefits of Using React useReducer Hook
Major benefits of using the useReducer()
hook in React are:
- Helps consolidate related states in one place.
- Helps implement multiple actions on an application entity.
- Reducer state and functions can be refactored outside of a component.
- Reducer state and functions are reusable from multiple components.
- Reducer state and functions are reinitializable with different states for different components.
- The
dispatch
API allows us derive individual functions for handling action types.
React useReducer Compared with Redux
Reducers form the backbone of Redux which is a lightweight client side state management solution for React. The idea of consolidating states into one is essential in both Redux and useReducer()
, as well as the notion of action types and dispatching them with the dispatch()
API. In addition, the structure of the reducer function is the same in both Redux and useReducer()
.
However, Redux is tailored for managing client state globally, often over a vast majority of components in a React app. It uses a global store composed of individual reducer slices. Each slice would have its own reducer function, state and action types. And slices are combined to make up a global store. In contrast, useReducer()
implements standalone reducers, which compare as single slices in a Redux store.
Lazy Initialization with useReducer
One of the optimization techniques from React’s useReducer is lazy initialization, which allows initializing state only once a component mounts. This approach is especially helpful for expensive computations or asynchronous operations, as it improves performance by avoiding extra calculations or API calls on each render.
Why Lazy Initialization?
By default, when you pass an initial state to useReducer, its computation happens immediately on component mount—even if that initial state is computationally heavy or data-intensive. Lazy initialization lets you delay calculating the initial state until it’s actually required. This is useful for:
- Avoiding performance overhead due to complex initial state computation.
- Deferring costly or async operations until the component’s first render.
- Gaining better control over when the initial state setup occurs.
How to Implement Lazy Initialization with useReducer
UseReducer supports lazy initialization by passing an initializer function as the third argument. This function only runs on the first render, giving you granular control over setting the initial state. Here’s a breakdown:
- Initializer Function: A function that returns the initial state, running only once during the first render.
- Passing Initial State and Initializer: Call useReducer with the initializer function as the third argument and a placeholder initial state as the second.
Below is an example showing how to perform lazy initialization for a list of posts in a reducer.
const initialState = {
posts: [],
loading: false,
error: null,
};
// Initializer function to load posts
const initializePosts = () => {
const savedPosts = JSON.parse(localStorage.getItem("posts"));
return savedPosts || initialState;
};
function postsReducer(state, action) {
switch (action.type) {
case "ADD_POST":
return { ...state, posts: [...state.posts, action.payload] };
case "DELETE_POST":
return {
...state,
posts: state.posts.filter((post) => post.id !== action.payload.id),
};
default:
return state;
}
}
export default function PostsComponent() {
// Lazy initialization
const [state, dispatch] = useReducer(
postsReducer,
initialState,
initializePosts,
);
return (
<div>
<h1>Posts</h1>
<ul>
{state.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button
onClick={() =>
dispatch({
type: "ADD_POST",
payload: { id: Date.now(), title: "New Post" },
})
}
>
Add Post
</button>
</div>
);
}
In this example, initializePosts fetches initial data from localStorage only once when the component mounts, avoiding reloading or recomputing on every render.
When to Use Lazy Initialization with useReducer
Lazy initialization is beneficial when:
- The Initial State is Data-Heavy: For initial states requiring large datasets or complex structures, delaying its computation can speed up the initial render.
- Asynchronous Data is Needed: Lazy initialization helps when initial state values come from an API, database, or local storage, such as loading saved user preferences or cached data.
- You Want Improved Performance in Critical Components: For components managing complex state with useReducer, lazy initialization can reduce render overhead.
Key Points to Remember
- Only Runs on Mount: The initializer function only runs on the first render, keeping future renders efficient.
- Reducers Must Have Pure Functions: Ensure the initializer avoids side effects; it should solely compute and return the initial state.
- Error Handling in Initialization: If your initializer pulls data from an API or other sources, add error handling to manage cases where data may be missing or invalid.
Using lazy initialization with useReducer offers flexibility in state management, especially for applications that need optimized performance. It ensures complex computations or data fetching only happen when necessary, helping to keep React components smoother and more efficient.
Summary
In this post, explored with a demo app, how to use the React userReducer()
for consolidating component state into a reducer. We learned that useReducer()
initializes a React reducer with state and dispatch()
function for invoking actions on the state. We followed examples from a postsReducer
function to understand what constitutes a reducer function and discussed the good practices involved while writing efficient reducer function. We also explored how useReducer()
with a reducer helps implement state actions at multiple levels of its nesting.
Towards the end, we outlined the benefits of using useReducer()
and learned how it compares to global use of reducers in Redux.