State Management in React Using `useReducer`: A Comprehensive Guide

Md Enayetur Rahman - Aug 12 - - Dev Community

When building complex React applications, managing state efficiently becomes a crucial aspect of maintaining a clean and scalable codebase. While useState is ideal for simple state management, useReducer offers a more structured approach, especially when dealing with multiple related state values or complex state transitions.

In this blog, we’ll dive deep into useReducer, exploring how it works and why it can be an excellent choice for managing state in React. We’ll walk through a practical example, focusing on using initialState separately and how to manage state transitions using the dispatch method, reducer function, and action objects.

What is useReducer?

useReducer is a React hook that provides an alternative to useState for managing state. It is particularly useful when the state logic is complex and involves multiple sub-values or when the next state depends on the previous one. It works similarly to how reducers work in Redux, but is built into React and doesn’t require any external library.

Understanding the Key Components

Before we jump into the code, let’s clarify the key components of useReducer:

  1. State: The state is an object or value that your component manages and reacts to. It’s the data that drives your UI.
  2. Dispatch: dispatch is a function that allows you to send actions to the reducer. When you call dispatch, you pass an action object that describes what kind of change should happen to the state.
  3. Reducer Function: The reducer function is where you define how the state should change based on the action. It takes the current state and the action as arguments and returns the new state.

Setting Up useReducer with initialState

Let’s look at a practical example where we manage a state object with three properties: title and description (both strings) and items (an array).

import React, { useReducer } from 'react';

// Define the initial state object separately
const initialState = {
    title: '',
    description: '',
    items: []
};

// Define the reducer function
function reducer(state, action) {
    switch (action.type) {
        case 'SET_TITLE':
            return { ...state, title: action.payload };
        case 'SET_DESCRIPTION':
            return { ...state, description: action.payload };
        case 'ADD_ITEM':
            return { ...state, items: [...state.items, action.payload] };
        default:
            return state;  // Return the current state for any unknown actions
    }
}

const MyComponent = () => {
    const [state, dispatch] = useReducer(reducer, initialState);

    return (
        <div>
            <input
                type="text"
                placeholder="Title"
                value={state.title}
                onChange={(e) =>
                    dispatch({ type: 'SET_TITLE', payload: e.target.value })
                }
            />
            <input
                type="text"
                placeholder="Description"
                value={state.description}
                onChange={(e) =>
                    dispatch({ type: 'SET_DESCRIPTION', payload: e.target.value })
                }
            />
            <button
                onClick={() =>
                    dispatch({ type: 'ADD_ITEM', payload: `Item ${state.items.length + 1}` })
                }
            >
                Add Item
            </button>
            <h3>{state.title}</h3>
            <p>{state.description}</p>
            <ul>
                {state.items.map((item, index) => (
                    <li key={index}>{item}</li>
                ))}
            </ul>
        </div>
    );
};

export default MyComponent;
Enter fullscreen mode Exit fullscreen mode

Breakdown of the Code

Let’s walk through the code step by step to understand how each part works:

  1. Initial State:
const initialState = {
    title: '',
    description: '',
    items: []
};
Enter fullscreen mode Exit fullscreen mode

The initialState object holds the starting values for our component's state. Here, we have two strings (title and description) and an array (items). By defining initialState separately, the code becomes cleaner and more modular, making it easier to manage and update.

  1. Reducer Function:
function reducer(state, action) {
    switch (action.type) {
        case 'SET_TITLE':
            return { ...state, title: action.payload };
        case 'SET_DESCRIPTION':
            return { ...state, description: action.payload };
        case 'ADD_ITEM':
            return { ...state, items: [...state.items, action.payload] };
        default:
            return state;
    }
}
Enter fullscreen mode Exit fullscreen mode

The reducer function determines how the state should change based on the actions dispatched. It takes two arguments: the current state and an action object.

  • Action Object: The action object has two main properties: type (a string that describes the action) and payload (the data needed to update the state).

  • Switch Statement: The switch statement checks the type of action and updates the state accordingly. For example, if the action type is SET_TITLE, it returns a new state object with the updated title, while other properties remain unchanged.

  • Default Case: If the action type doesn’t match any cases, the default case returns the current state, ensuring no unintended changes occur.

  1. Using useReducer in the Component:
const [state, dispatch] = useReducer(reducer, initialState);
Enter fullscreen mode Exit fullscreen mode

useReducer initializes the state and returns two values: the current state and the dispatch function. The state contains the current values of title, description, and items. The dispatch function is used to send actions to the reducer to update the state.

  1. Handling User Input:
onChange={(e) => dispatch({ type: 'SET_TITLE', payload: e.target.value })}
Enter fullscreen mode Exit fullscreen mode

When the user types into the title input field, an onChange event triggers dispatch with an action object. The reducer then updates the title in the state based on the provided payload.

  1. Adding an Item:
onClick={() => dispatch({ type: 'ADD_ITEM', payload: `Item ${state.items.length + 1}` })}
Enter fullscreen mode Exit fullscreen mode

When the "Add Item" button is clicked, the dispatch function sends an action to the reducer to add a new item to the items array. The new item is generated based on the current length of the array.

  1. Rendering the UI:
<h3>{state.title}</h3>
<p>{state.description}</p>
<ul>
    {state.items.map((item, index) => (
        <li key={index}>{item}</li>
    ))}
</ul>
Enter fullscreen mode Exit fullscreen mode

Finally, the component renders the current state values. The title and description are displayed as text, and the items array is rendered as a list.

Advantages of Using useReducer

  • Centralized State Management: The reducer function centralizes state updates, making the logic easier to manage and test.
  • Scalable State Logic: useReducer is ideal for managing complex state transitions, especially when multiple actions need to be handled.
  • Predictable State Transitions: The use of action types and a switch statement ensures that state transitions are predictable and controlled.

Conclusion

useReducer is a powerful tool for managing state in React, especially when dealing with complex state objects or transitions. By separating the initial state, using a well-defined reducer function, and dispatching actions with clear types and payloads, you can create a robust and scalable state management system within your components.

In this blog, we’ve explored how to set up useReducer, manage state with multiple properties, and handle state transitions effectively. Understanding these concepts is crucial for building advanced React applications that are easy to maintain and extend. In a future blog, we’ll explore how the Context API can be combined with useReducer to manage global state across your application.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .