Skip to content

useState and useReducer Patterns

Aman Kumar Singh8 min read
Part 3 of 50From the React & Next.js Complete Guide series
useState and useReducer Patterns — article by Aman Kumar Singh

In Understanding React Hooks in Depth, I covered how hooks actually work under the hood: the fiber-linked list, why call order matters, and why the rules of hooks aren't arbitrary. That article was about mechanics. This one is about a decision every component forces on you: when a piece of local state grows past a single useState call, what do you reach for next?

This is part of the React & Next.js Complete Guide series, which I'm building as a practical companion to the Full Stack SaaS Masterclass. Most teams I've worked with don't have a useReducer problem. They have a "five useState calls that update together and sometimes get out of sync" problem, and they reach for Redux or Zustand to fix it when a reducer sitting right there in React would have done the job.

I want to make the case for treating useState and useReducer as two points on the same spectrum rather than two unrelated tools. The choice between them comes down to where you want your update logic to live, and how much you're willing to pay upfront for consistency.

Why useState stops being enough

useState is perfect for state that's genuinely independent. A boolean for a modal, a string for an input, a number for a counter. Each of these has one writer and one reader, and there's no rule connecting them to any other piece of state in the component.

The trouble starts when state stops being independent. Take a typical form with validation:

function SignupForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [emailError, setEmailError] = useState<string | null>(null);
  const [passwordError, setPasswordError] = useState<string | null>(null);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setEmailError(null);
    setPasswordError(null);
    setSubmitError(null);

    if (!email.includes('@')) {
      setEmailError('Enter a valid email');
      return;
    }
    if (password.length < 8) {
      setPasswordError('Password must be at least 8 characters');
      return;
    }

    setIsSubmitting(true);
    try {
      await submitSignup({ email, password });
    } catch (err) {
      setSubmitError('Signup failed, try again');
    } finally {
      setIsSubmitting(false);
    }
  }

  // ...
}

Nothing here is technically wrong, but the component has six pieces of state that all belong to one logical thing: the form's status. There's no single place that describes what "clean submit" or "validation failed" actually mean as states. Every new field means two more useState calls and another set of manual resets scattered through handleSubmit. Miss one reset and you get a stale error message sitting next to a field the user already fixed. That bug class, forgetting to clear related state, is the tell that you're past the point where independent useState calls model your domain well.

What useReducer actually buys you

useReducer doesn't shrink the amount of code you write. In most cases it's more code than the equivalent useState version. The payoff shows up elsewhere: every possible transition gets a name, and the reducer function becomes the only place allowed to decide what the next state looks like.

type FormState = {
  email: string;
  password: string;
  emailError: string | null;
  passwordError: string | null;
  status: 'idle' | 'validating' | 'submitting' | 'error' | 'success';
  submitError: string | null;
};

type FormAction =
  | { type: 'FIELD_CHANGED'; field: 'email' | 'password'; value: string }
  | { type: 'VALIDATION_FAILED'; field: 'email' | 'password'; message: string }
  | { type: 'SUBMIT_STARTED' }
  | { type: 'SUBMIT_SUCCEEDED' }
  | { type: 'SUBMIT_FAILED'; message: string };

const initialState: FormState = {
  email: '',
  password: '',
  emailError: null,
  passwordError: null,
  status: 'idle',
  submitError: null,
};

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case 'FIELD_CHANGED':
      return {
        ...state,
        [action.field]: action.value,
        [`${action.field}Error`]: null,
        submitError: null,
      };
    case 'VALIDATION_FAILED':
      return { ...state, [`${action.field}Error`]: action.message };
    case 'SUBMIT_STARTED':
      return { ...state, status: 'submitting', submitError: null };
    case 'SUBMIT_SUCCEEDED':
      return { ...state, status: 'success' };
    case 'SUBMIT_FAILED':
      return { ...state, status: 'error', submitError: action.message };
    default:
      return state;
  }
}

The component that consumes this reducer becomes a dispatcher of intent rather than a manager of six variables:

function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialState);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (!state.email.includes('@')) {
      dispatch({ type: 'VALIDATION_FAILED', field: 'email', message: 'Enter a valid email' });
      return;
    }
    if (state.password.length < 8) {
      dispatch({
        type: 'VALIDATION_FAILED',
        field: 'password',
        message: 'Password must be at least 8 characters',
      });
      return;
    }

    dispatch({ type: 'SUBMIT_STARTED' });
    try {
      await submitSignup({ email: state.email, password: state.password });
      dispatch({ type: 'SUBMIT_SUCCEEDED' });
    } catch {
      dispatch({ type: 'SUBMIT_FAILED', message: 'Signup failed, try again' });
    }
  }

  // ...
}

Two things changed that matter beyond readability. First, FIELD_CHANGED clears both the field's own error and any leftover submit error in one place, so you can't forget to reset it from some other call site later. Second, status is a single string instead of an isSubmitting boolean plus an implicit "did it fail" derived from submitError being truthy. You can't accidentally end up with isSubmitting: true and submitError both set, because the reducer only ever produces states that make sense together. That's the actual value: illegal states become unrepresentable, or at least harder to reach by accident.

The tradeoff nobody mentions: reducers hide the "why" from the caller

The cost of this pattern is indirection. When you read setEmailError('Enter a valid email'), you know exactly what happens next: emailError becomes that string. When you read dispatch({ type: 'VALIDATION_FAILED', field: 'email', message: '...' }), you have to go read the reducer to know what actually changes, including any side effects on other fields like the submitError reset above.

For a small form, that indirection is a real cost. If your reviewer has to jump between the dispatch call site and the reducer switch statement every time they read the component, you've added a layer for a problem that might not exist yet. I'd only reach for useReducer once I can point at an actual bug, or actual complexity, like the "forgot to clear related state" issue above. Reaching for it because it looks more architecturally serious isn't a good enough reason. Complexity you add preemptively is complexity you're stuck maintaining even if it never pays for itself.

Combining useReducer with context for shared local state

A pattern that shows up a lot in dashboard-style SaaS UIs: several sibling components need to read and dispatch against the same reducer, but the state genuinely belongs to one page or feature, not the whole app. This is where useReducer plus useContext earns its keep, without needing a global store.

const FilterStateContext = createContext<FilterState | null>(null);
const FilterDispatchContext = createContext<React.Dispatch<FilterAction> | null>(null);

function FilterProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(filterReducer, initialFilterState);

  return (
    <FilterStateContext.Provider value={state}>
      <FilterDispatchContext.Provider value={dispatch}>
        {children}
      </FilterDispatchContext.Provider>
    </FilterStateContext.Provider>
  );
}

function useFilterState() {
  const context = useContext(FilterStateContext);
  if (!context) throw new Error('useFilterState must be used within FilterProvider');
  return context;
}

function useFilterDispatch() {
  const context = useContext(FilterDispatchContext);
  if (!context) throw new Error('useFilterDispatch must be used within FilterProvider');
  return context;
}

Splitting state and dispatch into two contexts is deliberate. dispatch from useReducer is referentially stable across renders, so a component that only needs to fire actions and never reads state can subscribe to FilterDispatchContext and never re-render when the filter state changes. A component that only reads state subscribes to FilterStateContext instead. If you merge both into one context object, every consumer re-renders on every state change regardless of what it actually uses, which defeats a chunk of the reason to bother splitting things out in the first place.

This pattern is a genuine middle ground. It's heavier than local useReducer, and lighter than pulling in Redux or Zustand for state that only three components on one page care about. I'd reach for a real state management library only once this pattern needs to cross multiple unrelated routes or survive outside a single page's lifetime.

Production pitfalls worth knowing up front

A few things bite people specifically with useReducer, more than with useState.

Reducers must stay pure. No fetch calls, no Date.now(), no random IDs generated inside the switch statement. If you need a side effect triggered by a state transition, do it in a useEffect that watches the resulting state, or dispatch an action after the async work resolves, as in the submit example above. A reducer that calls out to the network is a reducer that breaks React's ability to reason about renders and makes testing it require mocking things that shouldn't need mocking.

Actions that carry too much responsibility are a smell. If you find yourself writing a switch case that runs twenty lines of branching logic, extract that logic into a plain function the reducer calls, and keep the reducer itself as a thin dispatch table. It stays easier to test and easier to read as a list of transitions rather than a wall of business logic.

Don't reach for useReducer just because a component "has a lot of state." Count the number of state variables that actually change together, not the total number of useState calls. A component with ten independent booleans for ten independent UI toggles doesn't need a reducer. A component with three fields that must stay consistent with each other does, even if that's a smaller total count.

Key takeaways

  • Reach for `useReducer` when state variables change together and need to stay consistent, not just because there are many `useState` calls.
  • A reducer centralizes every valid state transition in one function, which makes it much harder to forget to reset related state.
  • The cost of `useReducer` is indirection: you have to read the reducer to know what a `dispatch` call actually does, so don't add it preemptively.
  • Splitting `useReducer` state and `dispatch` into separate contexts avoids re-rendering consumers that only care about one or the other.
  • Keep reducers pure. Side effects belong in `useEffect` or in code that dispatches an action once an async operation resolves.
  • `useReducer` plus context is a real middle ground before you need a full state management library, useful for state scoped to one page or feature.

Frequently asked questions

Is useReducer just a smaller Redux?

It's the same core idea, a pure function taking state and an action and returning new state, but without a store, middleware, or DevTools out of the box. `useReducer` is scoped to one component tree via context, whereas Redux (or Zustand) is meant for state accessed from anywhere in the app.

Should I use useReducer for every form?

No. Simple forms with one or two fields and no interdependent validation are usually clearer with individual `useState` calls. Reach for `useReducer` once fields need to reset each other's errors, once submission status has more than two meaningful values, or once you notice bugs from forgetting to clear related state.

Can I use TypeScript discriminated unions with useReducer?

Yes, and you should. Modeling actions as a discriminated union, like the `FormAction` type above, gives you exhaustive type checking in the reducer's switch statement and autocomplete on `dispatch` calls, catching typos in action types at compile time instead of at runtime.

Does useReducer replace useState entirely once I adopt it?

No, and you shouldn't try to force that. Independent UI state, like whether a dropdown is open, is still clearer as its own `useState`. Mixing both in the same component is normal: `useReducer` for the state that needs consistency guarantees, `useState` for state that doesn't.

How do I test a component that uses useReducer?

Test the reducer function directly as a pure function: call it with a known state and action, assert on the returned state. That's cheaper and more reliable than rendering the component and simulating events for every transition. Save component-level tests for the parts that depend on rendering, like which error message shows up in the DOM.

Related articles

Zustand for Client State — Aman Kumar Singh
Avoiding Unnecessary Re-Renders — Aman Kumar Singh
Using React Context the Right Way — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.