wishlist app screenshot

Wishlist App

4 min read
TypescriptTailwind CSSViteReact

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 to change the state in your app - we call this an action, anything from a button click 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);

The arguments of the useReducer hook include:

  • reducer: A pure 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.
  • initialState: the initial state of your application

The useReducer hook returns the following:

  • state: The current state of your application
  • dispatch: The function that sends actions to the reducer. It sends declarative instructions to the reducer that tell it how to update the state. For example:
dispatch({
  type: "ADD_WISH",
  payload: { wish: "Buy a pair of earbuds", priority: "High" },
});

Instead of directly manipulating the current state, the reducer returns and sets a new state in our app based on our action and an optional payload object that can aid in state operations (we'll dive more into this soon). The reducer takes care of the "how" while our ui components take care of the "what," specifically through the dispatch.

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 (what are they wishing for?), whether the wish has been completed or not, and a priority level. Cool, now off the top of my head, what basic functionality will the app require for each wish item? Well, completion toggling, a unique identifier for each wish and basic chronological operations sounds 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 components that handles user interactivity, the UI! 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 those selected wishes? What about basic error messages? Our state needs 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
  • dark or light mode: the ui mode to display can be added here
  • bulk selection mode: whether we are in bulk selection mode or not

This led me to write 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[];
  ui: {
    darkMode: boolean;
    bulkSelectionMode: boolean;
  };
}

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. 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. Like we talked about before, actions serve as the events that trigger state updates. Each action has a type (a string defining the intent) and an optional payload (additional data needed to carry out the operation). Defining actions upfront makes the reducer much 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.

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, and returns a new 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.

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. The logic in this map method allows us to return a new wishItems array!

That's the main gist of it, the other cases follow similar processes, using appropriate Javascript methods where necessary such as the .forEach() and .filter() methods, which I'll leave to you to understand. 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? In short, I define the INITIAL_STATE of our app as an empty WishlistState object.

  const INITIAL_STATE: WishlistState = {
    wishItems: [],
    filter: {
      priorities: [],
      status: null,
    },
    errors: {},
    selectedWishes: [],
  };
}

Then, I pass it, along with our wishlistReducer function, in the useReducer hook as such:

const [state, dispatch] = useReducer(wishlistReducer, INITIAL_STATE);

Once again, our useReducer setup involves the following:

  • Hook Arguments:
    • wishlistReducer - our reducer function
    • initialState - the initial state of our app
  • Hook returns:
    • state: the current state managed by the reducer
    • dispatch: 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 "ADD_ACTION" 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 would 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>;
}

However, looking ahead the question becomes, do we really want to pass down state and dispatch props to every single component we create? The simple answer is no, that would get too complicated. The solution is to use React's context API and wrap the whole app in a Wishlist Context Provider, so that any component in the app can consume the state and use the dispatch functions. I'll leave it to you to see how I implemented this.

So there you go. 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, 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.