Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 2

Local Component State: useState and useReducer

Recommended resources

Hello! Welcome back.

In our last lesson, we established a mental model for the React component lifecycle, covering how components mount, update, and unmount. We saw that state changes are the primary trigger for updates. Now, we'll dive into the two main hooks React provides for managing that state: useState and useReducer.

Your goal is to understand not just how to build apps, but how to build them well—making them performant and maintainable. A crucial part of that is choosing the right tool for managing state. This lesson will equip you to make that decision by exploring the mechanics and trade-offs of useState and useReducer for state of varying complexity.

useState: The Foundation of React State

You're already familiar with useState from your previous work. It's the most common hook for state management, and for good reason. It's simple, direct, and perfect for managing state that is independent and has simple transitions.

The basic signature is:
const [state, setState] = useState(initialState);

  • state: The current value.
  • setState: A function to update the value and trigger a re-render.
  • initialState: The value the state will have on the initial render.

useState excels when you're managing primitive values (strings, numbers, booleans) or simple objects/arrays that don't have complex update logic.

A great example is managing a simple toggle, like a dark mode switch. All you need is a boolean state and a way to flip it. Using useReducer here would be overkill, adding unnecessary complexity.

useReducer vs useState Data Flow
This flowchart shows the conceptual difference between the two hooks. `useState` is a direct update (`setState` -> `Re-Render`), while `useReducer` introduces an intermediary step (`Dispatch` -> `Reducer` -> `Re-Render`) that contains the update logic.

useReducer: For More Predictable State Transitions

When state logic becomes more complex, useState can become cumbersome. You might find yourself writing convoluted logic inside event handlers or even needing multiple useState calls for pieces of state that are related and often change together.

This is where useReducer comes in. It's an alternative to useState that is better suited for managing complex state objects and predictable, well-defined state transitions. Its structure is inspired by the pattern used in state management libraries like Redux.

The signature for useReducer is:
const [state, dispatch] = useReducer(reducer, initialState);

Let's break down the new parts:

  • reducer: A pure function that takes the current state and an action object, and returns the new state. Its form is (state, action) => newState.
  • dispatch: A function you call to "dispatch" an action. This action is an object that describes the state change you want to make. By convention, it has a type property (e.g., { type: 'INCREMENT' }).

The video below provides an excellent, practical walkthrough of setting up and using useReducer, starting with a simple counter and then adding complexity.

Learn React Hooks: useReducer - Simply Explained!

Let's watch 'Learn React Hooks: useReducer - Simply Explained!' from Cosden Solutions. It does a great job of introducing the core concepts and then shows how useReducer scales gracefully as your logic becomes more complex.

Watch from 00:55 to 12:25. Pay close attention to: The structure of the reducer function (with the switch statement). How the dispatch function is used to send actions from the UI. How he refactors the simple counter to include validation logic, updating both count and error state in a single, predictable action. This demonstrates the power of centralizing logic.

As you saw in the video, the key benefits of useReducer are:

  1. Decoupling Logic from Components: The state update logic lives in the reducer function, outside of your component's event handlers. This makes your component cleaner and the logic easier to test.
  2. Handling Complex Transitions: When updating one piece of state depends on another (like setting an error message when a count goes out of bounds), you can handle it all within a single reducer case. This makes state transitions atomic and predictable.

Passing Data with Actions: The payload

Often, you need to pass data along with an action. For instance, when adding a new item to a list, you need to specify what that item is. This is typically done by adding a payload property to the action object.

// Dispatching an action with a payload
dispatch({ type: 'ADD_TODO', payload: 'Learn React Native performance' });

// The reducer can then access this payload
function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      // The new state includes the item from the payload
      return [...state, { id: Date.now(), text: action.payload, completed: false }];
    default:
      return state;
  }
}

The next video demonstrates this pattern with a classic to-do list example, which is a great way to see useReducer managing a more complex state shape (an array of objects).

Learn useReducer In 20 Minutes

In 'Learn useReducer In 20 Minutes' by Web Dev Simplified, you'll see how to use payloads to pass data to your reducer. This is a fundamental pattern for building dynamic applications.

Watch from 07:46 to 13:00. Focus on how the name of the new to-do is passed into the dispatch call within a payload object, and how the reducer uses action.payload.name to create the new state.


Deciding: useState or useReducer?

So, when should you choose one over the other? This is a critical question for writing clean and maintainable code. The following article by Kent C. Dodds is one of the best resources on this topic. It provides clear, actionable advice based on the nature of the state you are managing.

Should I useState or useReducer?

The article 'Should I useState or useReducer?' provides two excellent case studies: a useDarkMode hook where useState is the clear winner, and a useUndo hook where useReducer elegantly solves problems that are tricky to handle with useState.

Please read the following sections: 'Custom useUndo hook': Read through both the useState and useReducer implementations. Pay special attention to the 'stale closure' bug he describes in the useState version. Understanding this bug is key to appreciating why useReducer is safer for interdependent state. 'useReducer implementation' (for the useUndo hook): Notice how simple and clean the hook becomes, naturally avoiding the bugs. 'Conclusion': This summarizes the rules of thumb for when to use each hook.

The key takeaways from the article are:

  • Use useState when managing simple, independent state.
  • Use useReducer when state updates are complex or when the next state depends on the previous one. More specifically, if updating one piece of state requires you to know the value of another piece of state, useReducer is often a better choice.

Let's see this in a more realistic example, like a login form. With useState, you might have:

const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isLoggedIn, setIsLoggedIn] = useState(false);

All these states are related. When a user tries to log in, you might set isLoading to true and clear any previous error. If it fails, you set isLoading to false, set a new error, and clear the password field. Managing these transitions with multiple setState calls can get messy.

With useReducer, you can encapsulate all this logic into a single state object and a reducer, which is much cleaner.

const initialState = {
  username: '',
  password: '',
  isLoading: false,
  error: '',
  isLoggedIn: false,
};

function loginReducer(state, action) {
  switch (action.type) {
    case 'LOGIN':
      return { ...state, isLoading: true, error: '' };
    case 'SUCCESS':
      return { ...state, isLoading: false, isLoggedIn: true };
    case 'ERROR':
      return { ...state, isLoading: false, error: 'Login failed!', password: '' };
    // ... other cases
  }
}

The LogRocket article, "A guide to the React useReducer Hook," contains a full implementation of this login form refactor in the section "Creating a login component," which is a great practical reference.


Key Takeaways

  • useState is your default choice for simple, independent component state (e.g., toggles, form inputs).
  • useReducer is preferable for complex state logic, especially when you have multiple sub-values or when the next state depends on the previous one. It centralizes your update logic into a single pure function.
  • The reducer function is a pure function: (state, action) => newState. It receives the current state and an action and must return a new state object without mutating the original.
  • The dispatch function sends an action object to the reducer to trigger a state update.
  • Actions can carry a payload to provide the reducer with the data it needs to compute the new state.

Mastering both useState and useReducer allows you to tailor your state management approach to the complexity of the problem, a key skill for building robust applications.

In our next lesson, we will explore useEffect, the hook that lets you handle side effects like data fetching, subscriptions, and manual DOM manipulations in your components.