Managing Complex State: From useState to useReducer
This guide assumes you have some foundational knowledge of the useState hook in React, which is a great entry point in understanding how state is managed in a web app. However, many real-world applications require more efficient ways of handling the state of an app, and things can get complicated quickly. If you've ever built a React app with only useState
, it's likely you might hit a wall when managing complex interconnected state (so many const[state, setState]
- esque lines!!!).
Consider the state of an e-commerce shopping cart. We'd need to handle the product quanity, various attributes like size and color, manage remove/undo operations, and much more. When state logic becomes this intricate, leveraging React's useReducer hook can provide a more predictable, structured way to handle state which ends up simplifying our code and improving its maintainability. I hope to illustrate soon.
To better understand the functionality of the useReducer
hook, I decided to build a wishlist application with a friendly and modern user interface that handles state operations. I hope this guide can illustrate the power of this hook and provide a good starting point on how to use it.
useReducer TL;DR
useReducer
follows a simple pattern: when something happens in your app - we call this an action, which you define and can be anything from a button clicked to a slider moving - it goes through a reducer function that knows exactly how to update your state based on that action. The basic syntax is as follows:
const [state, dispatch] = useReducer(reducer, initialState);
Take note of the reducer argument in the hook, this is a function that receives the current state and an action, and promptly returns a new state based on what that action is trying to do. We'll go over the reducer function's implementation soon.
The dispatch function is how we send actions to the reducer. It sends declarative instructions to the reducer that tell it how to update the state. For example:
// Instead of setState({ ...state, wish: [...state.wishes, newWish] })
dispatch({
type: "ADD_WISH",
payload: { wish: "Buy a pair of earbuds", priority: "High" },
});
Instead of directly manipulating the state, we describe what we want to happen to the state (and provide an optional payload object that can aid state operations), and the reducer handles returning and setting a new state. The reducer takes care of the "how" while our ui components will take care of the "what".
Initial Design Steps and State Breakdown
Before we dive in to the state of our app, let's begin by visualizing how each wish item will be structured. Since we're using Typescript, let's take advantage of that!
First, what information will potential users want to see and modify? The simple answer is the wish description, whether the wish has been completed or not, and maybe even a priority level. Cool, now off the top of my head, what are some basic functionality a wishlist app will need to have for each wish item? Well, completion toggling, a unique identifier for each wish and basic chronological operations sound like a good start. So let's put it all together in the WishItem interface:
export interface WishItem {
id: string; // unique identifier
wish: string;
priority: "Low" | "Medium" | "High";
completed: boolean;
createdAt: Date;
}
Awesome, let's move on to the overall application state. When thinking about an app's state, it's important to not only consider our current wish items, but also the UI state that affects how users will be able to interact with them. On an app that displays a wishlist, should we include filtering options, and what should we filter by? What if a user wants to use bulk actions on a selected amount of wishes, where do we store the selected wishes? What about basic error messages? We need to track:
- wishItems: all the wish items
- filters: what filters are active
- errors: any error messages we want to show
- selectedWishes: which wishes are currently selected for bulk actions
This led me to the following WishlistState interface:
export interface WishlistState {
wishItems: WishItem[];
filter: {
priorities: Array<"Low" | "Medium" | "High">;
status: "Active" | "Completed" | null;
};
errors: {
wish?: string;
priority?: string;
prompt?: string;
};
selectedWishes: string[];
}
The filter object is structured to handle both priority and status filtering options. For priority filtering, the Array<"Low" | "Medium" | "High">
syntax lets us set multiple options for the priority level, and the errors object uses optional properties (that's what the ?
is for) to set specific error messages when needed.
Alright. Now it's important to define what actions a user will be able to take on the state of our wishlist. Actions in useReducer
serve as the 'events' that trigger state changes. Each action has a type (string defining the intent) and an optional payload (additional data needed to carry out the operation). Defining actions upfront makes the reducer more predictable and easier to debug.
I can think of quite a few actions we would need, like toggling completion of a wish, changing the priority level, deleting a wish, marking it as complete, modifying the description, etc. That's just for a single wish, what about bulk actions? Bulk deletion, update, marking as complete, and more should also be actions that we define. Therefore, I defined the following WishlistAction
type, with appropriate type strings and payload objects. It is usually good practice to have these defined.
export type WishlistAction =
// Individual wish operations
| { type: "ADD_WISH"; payload: Omit<WishItem, "id" | "createdAt"> }
| { type: "DELETE_WISH"; payload: string }
| {
type: "UPDATE_PRIORITY";
payload: { id: string; priority: WishItem["priority"] };
}
| { type: "TOGGLE_COMPLETION"; payload: string }
| { type: "TOGGLE_SELECT"; payload: string }
// Bulk operations for selected wishes
| { type: "BULK_DELETE" }
| { type: "BULK_UPDATE_PRIORITY"; payload: WishItem["priority"] }
| { type: "BULK_MARK_AS_COMPLETE" }
| { type: "BULK_MARK_AS_INCOMPLETE" }
| { type: "CLEAR_SELECTED" }
| { type: "TOGGLE_SELECT_ALL" }
| { type: "CLEAR_COMPLETED" }
// Error handling
| { type: "SET_ERROR"; payload: Partial<WishlistState["errors"]> }
| { type: "CLEAR_ERRORS" }
// Filter management
| { type: "SET_FILTER"; payload: Partial<WishlistState["filter"]> };
A couple of notes on typescript features here.
Omit<WishItem, "id" | "createdAt">
tells TypeScript to use WishItem's type but leave out the id and createdAt fields (since we'll generate those when adding a wish)Partial<WishlistState["errors"]>
means we can provide some or all of the error fields with this action's payload.- Using string literal types (like "ADD_WISH") gives the IDE autocompletion features (if supported) whenever we dispatch these actions in our components code.
Now that we've defined our state structure and actions, let's move on to the heart of the useReducer
hook, the reducer function itself!
Reducer Function Implementation
Let's implement the reducer function. This function was defined in a reducer.ts
file as follows:
export function wishlistReducer(state: WishlistState, action: WishlistAction): WishlistState {...}
As explained before, it receives the current state and an action to take on that state. A concept that is crucial to understand is that this reducer function must be a pure function, meaning it must never modify the existing state but return a new state object. This concept is known as immutability and it tripped me up on a couple of occasions. Let's see this concept in action with one of the simplest examples, the "ADD_WISH" action type.
export function wishlistReducer(
state: WishlistState,
action: WishlistAction,
): WishlistState {
switch (action.type) {
case "ADD_WISH":
return {
...state,
wishItems: [
...state.wishItems,
{
id: nanoid(),
wish: action.payload.wish,
priority: action.payload.priority,
completed: action.payload.completed,
createdAt: new Date(),
},
],
};
//... other cases ...
}
}
First off, notice the switch statement here? It's used quite often with reducers to ensure we implement only the logic on the right action type, super useful for debugging! Next, notice the immediate return statement after we enter the "ADD_WISH" case, immutability is immediately shown here. We are going to return a NEW state object, so let's dive in to how we do that.
First, we spread the properties of the previous state with the spread operator ...state
to ensure we preserve properties we won't modify in this case. Next, because this is the "ADD_WISH" action, we must return a WishlistState
object with a new element in the wishItems array by spreading the previous wishItems with ...state.wishItems
and adding the new WishItem
object with the payload's properties! Recall how we defined this action:
{
type: "ADD_WISH";
payload: Omit<WishItem, "id" | "createdAt">;
}
The nanoid library was used here to create a unique id for the new wish, and the Date object was used to give it a creation date.
And that's how we define actions in the reducer function! Let's take a look at one more case that's a little more complicated, the "BULK_UPDATE_PRIORITY" action.
case "BULK_UPDATE_PRIORITY":
return {
...state,
wishItems: state.wishItems.map((wish) =>
state.selectedWishes.includes(wish.id)
? { ...wish, priority: action.payload }
: wish,
),
};
Again, notice that we're spreading the previous state's properties. However, this time we will iterate over the wishItems
array with map
and check if each wish's id
is included in the selectedWishes
array. If it is, we return a new wish object with the updated priority (while keeping previous properties) otherwise we return the wish object unchanged.
There it is, the other cases follow similar processes, using appropriate Javascript methods where necessary such as the .forEach()
and .filter()
methods. Feel free to take a look at the implementation of the other cases in the reducer function here and make sure you understand how immutability is ensured in ever case, it's important!
Wiring up the reducer with the UI components
Now, with our state structure and reducer function in place you may be asking yourself: How do we initialize the useReducer
hook and interact with it in our ui? Let's take a look at the first lines of code in the WishlistApp
component, the root component of the app.
export default function WishlistApp() {
const initialState: WishlistState = {
wishItems: [],
filter: {
priorities: [],
status: null,
},
errors: {},
selectedWishes: [],
};
const [state, dispatch] = useReducer(wishlistReducer, initialState);
// ... the rest of code
}
Simple, we first create an empty initialState
object of the WishlistState
type, this ensures the state has the necessary properties (like the wishItems
array, filter
object, etc.) to match the structure our reducer is expecting. Make sure you import these types at the top of the file! Setting up useReducer
involves the following:
- Hook Arguments:
wishlistReducer
- our reducer functioninitialState
- the initial state of our app
- Hook returns:
state
: the current state managed by the reducerdispatch
: a function to send actions to the reducer
That was easy, but how do we interact with the reducer function in our UI? To do that, we need to dispatch an action to the reducer. Take a look at the following handleSubmit
function in the WishlistForm
component.
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// ... error handling logic
dispatch({
type: "ADD_WISH",
payload: {
wish: wishInput,
priority,
completed: false,
},
});
setWishInput("");
setPriorityInput("");
dispatch({ type: "CLEAR_ERRORS" });
};
When the user submits the form with correct values in the text input and select fields, we call the dispatch
function to send an action of type "ADDACTION" to the reducer with the appropriate data in the payload. After dispatching that action, we reset the _local state of the form inputs (the ones used to store wish description and priority level) and reset the errors by sending a separate "CLEAR_ERRORS" action.
Because WishlistForm
was a separate component declared in a different file (for organizational purposes), we need to pass the dispatch function as a prop to this component, so that we could interact with the global state managed by the reducer. Here's how we define the dispatch function as a prop with Typescript:
interface WishlistFormProps {
//... other props
dispatch: React.Dispatch<WishlistAction>;
}
So there you go. Local state variables for the wish's text input and priority level were used to pass data to the reducer through the dispatch in the payload, and that's how we centralized state management and enforced predictable state changes with the useReducer
hook! This pattern becomes very useful as the app grows in complexity and features are added. The github repo should outline the implementation of the rest of the action types using the dispatch function which, so make sure you check it out for further review.
I also included some neat (and sometimes disfunctional) lightweight ai features too using the Hugging Face API, but I'll save the explanation for another time.
Conclusion
If you're looking to scale your React state management skills, try building your own app with useReducer
. Start small, define your state and actions, and watch your state logic become cleaner and easier to maintain!
Thanks for stopping by! Let me know if you have any advice or feedback. Here's the Wishlist App source code and live demo.