Type something to search...
useState vs useReducer: Choosing the Right State Hook

useState vs useReducer: Choosing the Right State Hook

Every React component that remembers something uses either useState or useReducer. Most of the time useState is the obvious pick: a boolean for a modal, a string for an input, a number for a counter. But as a component grows, you can end up with six useState calls, event handlers that update three of them at once, and bugs where one piece of state gets out of step with another.

That's usually the point where useReducer starts to make sense. It's not a more advanced version of useState. It's a different way to organize the same thing: instead of setting values directly, you describe what happened and let one function decide how state changes.

This post compares both hooks with concrete examples. You'll see how each one works, refactor a growing component from useState to useReducer, type reducers properly in TypeScript, and get a set of practical rules for choosing between them.

How useState Works

useState gives you a value and a setter:

import { useState } from "react";

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

A few details are worth knowing:

  • The setter replaces the value. Unlike this.setState in class components, it doesn't merge objects. If your state is an object, you spread the old one yourself.
  • Updater functions read the latest value. setCount((c) => c + 1) is safe to call several times in one event, while setCount(count + 1) uses the value from the current render each time.
  • Lazy initialization. Pass a function, useState(() => expensiveSetup()), and React calls it only on the first render.
  • Bail out on equal values. If the new value is the same as the old one (by Object.is), React skips re-rendering the children.
import { useState } from "react";

type Settings = { theme: "light" | "dark"; fontSize: number };

export function SettingsPanel() {
  const [settings, setSettings] = useState<Settings>({
    theme: "light",
    fontSize: 16,
  });

  function toggleTheme() {
    setSettings((s) => ({
      ...s,
      theme: s.theme === "light" ? "dark" : "light",
    }));
  }

  return (
    <button onClick={toggleTheme}>
      Theme: {settings.theme}, size {settings.fontSize}
    </button>
  );
}

For simple, independent values, this is all you need.

How useReducer Works

useReducer takes a reducer function and an initial state. It returns the current state and a dispatch function:

import { useReducer } from "react";

type State = { count: number };
type Action = { type: "increment" } | { type: "decrement" } | { type: "reset" };

function counterReducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    case "reset":
      return { count: 0 };
  }
}

export function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </div>
  );
}

The reducer receives the current state and an action, and returns the next state. It must be pure: no API calls, no mutations, no randomness. React may call it more than once in development to check that.

For a counter, this is clearly more code than useState. The payoff only shows up when state gets more involved.

Lazy Initialization With useReducer

useReducer accepts a third argument, an init function. React calls init(initialArg) once on mount:

import { useReducer } from "react";

type State = { items: string[] };
type Action = { type: "add"; item: string };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "add":
      return { items: [...state.items, action.item] };
  }
}

function loadFromStorage(key: string): State {
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as State) : { items: [] };
  } catch {
    return { items: [] };
  }
}

export function useStoredList(key: string) {
  return useReducer(reducer, key, loadFromStorage);
}

This avoids reading localStorage on every render, the same way passing a function to useState does.

A Component That Outgrows useState

Here's a data-loading component written with useState. It works, but notice how many places have to update several values together:

import { useState } from "react";

type User = { id: number; name: string };

export function UserSearch() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function search() {
    setIsLoading(true);
    setError(null);
    try {
      const res = await fetch(`/api/users?q=${encodeURIComponent(query)}`);
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      setResults(await res.json());
    } catch (e) {
      setError(e instanceof Error ? e.message : "Unknown error");
      setResults([]);
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button onClick={search} disabled={isLoading}>
        Search
      </button>
      {error && <p role="alert">{error}</p>}
      <ul>
        {results.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
    </div>
  );
}

The problems are subtle:

  • Nothing stops you from having isLoading: true and error: "..." at the same time if you forget one setter.
  • The rules for "what does starting a search do to state" are spread across a handler.
  • Adding a "retry" button or "clear" button means copying the same groups of setter calls.

Refactoring to useReducer

Move the transitions into a reducer. The component describes what happened, the reducer decides the next state:

import { useReducer } from "react";

type User = { id: number; name: string };

type State = {
  query: string;
  status: "idle" | "loading" | "success" | "error";
  results: User[];
  error: string | null;
};

type Action =
  | { type: "queryChanged"; query: string }
  | { type: "searchStarted" }
  | { type: "searchSucceeded"; results: User[] }
  | { type: "searchFailed"; error: string }
  | { type: "cleared" };

const initialState: State = {
  query: "",
  status: "idle",
  results: [],
  error: null,
};

function searchReducer(state: State, action: Action): State {
  switch (action.type) {
    case "queryChanged":
      return { ...state, query: action.query };
    case "searchStarted":
      return { ...state, status: "loading", error: null };
    case "searchSucceeded":
      return { ...state, status: "success", results: action.results };
    case "searchFailed":
      return { ...state, status: "error", error: action.error, results: [] };
    case "cleared":
      return initialState;
  }
}

export function UserSearch() {
  const [state, dispatch] = useReducer(searchReducer, initialState);

  async function search() {
    dispatch({ type: "searchStarted" });
    try {
      const res = await fetch(`/api/users?q=${encodeURIComponent(state.query)}`);
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      dispatch({ type: "searchSucceeded", results: await res.json() });
    } catch (e) {
      dispatch({
        type: "searchFailed",
        error: e instanceof Error ? e.message : "Unknown error",
      });
    }
  }

  return (
    <div>
      <input
        value={state.query}
        onChange={(e) =>
          dispatch({ type: "queryChanged", query: e.target.value })
        }
      />
      <button onClick={search} disabled={state.status === "loading"}>
        Search
      </button>
      <button onClick={() => dispatch({ type: "cleared" })}>Clear</button>
      {state.status === "error" && <p role="alert">{state.error}</p>}
      <ul>
        {state.results.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
    </div>
  );
}

What improved:

  • One status field replaces two booleans, so impossible combinations like "loading and errored" can't happen.
  • Every transition has a name. Reading the Action type tells you everything this component can do.
  • The reducer is plain TypeScript. You can unit test it without rendering anything.
  • Adding "Clear" was one case in the reducer and one dispatch call.

The async work still lives in the event handler. Reducers must stay synchronous and pure, so fetch never goes inside them.

Testing a Reducer

Because the reducer is just a function, testing it is straightforward with Vitest:

import { describe, expect, it } from "vitest";
import { searchReducer, initialState } from "./searchReducer";

describe("searchReducer", () => {
  it("clears the previous error when a search starts", () => {
    const failed = { ...initialState, status: "error" as const, error: "Boom" };
    const next = searchReducer(failed, { type: "searchStarted" });

    expect(next.status).toBe("loading");
    expect(next.error).toBeNull();
  });

  it("resets everything on clear", () => {
    const busy = { ...initialState, query: "ada", status: "success" as const };
    expect(searchReducer(busy, { type: "cleared" })).toEqual(initialState);
  });
});

To make this work, export searchReducer and initialState from their own module. Testing state transitions this way is much faster than driving a rendered component through clicks. For the component side, see testing React components with Vitest and Testing Library.

Typing Actions in TypeScript

The pattern above uses a discriminated union for actions: every action has a type string literal, and TypeScript narrows action inside each case. Inside case "searchSucceeded", action.results is typed as User[]. Inside case "cleared", there's no results property at all.

Two extra tricks make reducers safer.

Exhaustiveness Checking

If your reducer function has an explicit return type and every case returns, TypeScript reports an error when you add a new action type and forget to handle it, because the function can fall through and return undefined. You can make it explicit with a never check:

function assertNever(value: never): never {
  throw new Error(`Unhandled action: ${JSON.stringify(value)}`);
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "queryChanged":
      return { ...state, query: action.query };
    // ...other cases
    default:
      return assertNever(action);
  }
}

Typing the Hook Call

React 19's types infer state and action types from the reducer, so you rarely need generics on useReducer itself. Type the reducer's parameters and return value, and state and dispatch are typed correctly. If you want to read more about the pattern, discriminated unions for type-safe React props uses the same technique for component props.

Sharing dispatch Through Context

One practical benefit of useReducer: dispatch has a stable identity. It never changes between renders. That makes it ideal for passing deep into the tree without causing re-renders:

import { createContext, use, useReducer, type Dispatch, type ReactNode } from "react";

type Todo = { id: number; text: string; done: boolean };
type Action =
  | { type: "added"; text: string }
  | { type: "toggled"; id: number };

function todosReducer(todos: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case "added":
      return [...todos, { id: Date.now(), text: action.text, done: false }];
    case "toggled":
      return todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t));
  }
}

const TodosContext = createContext<Todo[]>([]);
const TodosDispatchContext = createContext<Dispatch<Action>>(() => {});

export function TodosProvider({ children }: { children: ReactNode }) {
  const [todos, dispatch] = useReducer(todosReducer, []);
  return (
    <TodosContext value={todos}>
      <TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
    </TodosContext>
  );
}

export const useTodos = () => use(TodosContext);
export const useTodosDispatch = () => use(TodosDispatchContext);

Components that only dispatch actions read TodosDispatchContext and don't re-render when the list changes. React 19 lets you render the context object itself as the provider, as shown. The same split works for larger apps, and it's often enough before you reach for a library. Avoiding prop drilling with the React Context API covers this approach in more depth.

Performance Differences

There's essentially no performance difference between the two. useState is implemented on top of the same machinery as useReducer internally. Pick based on readability, not speed.

The one practical difference is the stable dispatch mentioned above. A useState setter is also stable, but if you write custom handler functions that call several setters, those handlers are new on every render unless you wrap them in useCallback. With a reducer, you pass dispatch directly.

How to Choose

Use useState when:

  • State is a single primitive or a small object.
  • Updates are simple and independent ("set this value").
  • The component is small and the logic fits comfortably in a few handlers.

Use useReducer when:

  • Several pieces of state change together in response to one event.
  • The next state depends on the previous state in non-trivial ways.
  • You want named transitions you can read in one place and test in isolation.
  • You have a status machine (idle, loading, success, error) and want to rule out impossible combinations.
  • You need to pass update logic deep into the tree.

You can also mix them. A form might use useReducer for its complex submission state and a plain useState for whether a help tooltip is open.

Common Mistakes When Choosing a State Hook

  • Mutating state in a reducer. state.items.push(item); return state; returns the same reference, so React skips the update. Always return a new object or array.
  • Putting side effects in the reducer. Fetching, logging to a server, or writing to storage inside a reducer breaks purity and runs twice in Strict Mode. Do side effects in handlers or effects.
  • Using many booleans instead of a status. isLoading, isError, and isSuccess as separate useState calls allow invalid states. A single status field, in either hook, is safer.
  • Reaching for useReducer too early. A two-case reducer for a toggle is ceremony without benefit. Start with useState and refactor when the handlers get tangled.
  • Storing derived values. If filteredItems can be computed from items and filter, compute it during render instead of adding it to state.

Frequently Asked Questions (FAQ) About useState vs useReducer

No. They're built on the same internal mechanism and perform the same. Choose based on how readable and maintainable the state logic is, not performance.

The idea is the same, a pure function that takes state and an action and returns new state, but useReducer is local to one component. Redux and Redux Toolkit add a global store, middleware, DevTools time travel, and selectors. Many apps use useReducer plus context for small shared state and only move to a library when they need more.

No. Reducers must be synchronous and pure. Do the async work in an event handler or effect, and dispatch actions before and after it, such as searchStarted and searchSucceeded.

Yes. Use whichever fits each piece of state. It's common to keep a complex workflow in a reducer and small UI flags, like whether a dropdown is open, in useState.

In development with Strict Mode, React calls reducers twice to help you catch impure code. Only one result is used. If running it twice causes a bug, the reducer has a side effect or mutates state.

No. dispatch is guaranteed to keep the same identity across renders, so you can pass it to children or list it in dependency arrays without memoizing it.

Conclusion

useState and useReducer solve the same problem with different shapes. useState is direct and perfect for simple, independent values. useReducer centralizes transitions in one pure function, which pays off when multiple values change together, when you want to eliminate impossible state combinations, or when you want to test logic without rendering.

A good habit is to start with useState and watch for the signals: handlers that call three setters in a row, booleans that contradict each other, or the same update copied into several places. When you see them, move that state into a reducer with a typed action union. The refactor is usually mechanical, and the component becomes easier to read the next time you open it.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading