Lesson 23 of 5026 min read

State Management in Next.js: useState, useReducer & Context API

Learn the difference between local and global state in Next.js, and how useState, useReducer, and Context API each fit different needs.

Author: CodersNexus

State Management in Next.js: useState, useReducer & Context API

In Lesson 1.4, useState was introduced as the fundamental way to give a component memory of its own. But as an application grows, a critical architectural question emerges again and again: does this specific piece of state belong to just one component, or does it need to be shared and stay in sync across many different, possibly distant parts of the UI? Answering this question correctly — and picking the right tool for each case — is one of the most consequential decisions in structuring a maintainable Next.js application.

This lesson builds directly on useState to introduce useReducer, a hook better suited to state with complex, interrelated update logic, and the Context API, React's built-in mechanism for sharing state across a component tree without manually passing it down through every intermediate layer via props. Understanding when each tool is the right fit — and just as importantly, recognizing when simpler useState is genuinely sufficient — is the core skill this lesson builds.

Learning Objectives

  • Distinguish between local state and global state in a Next.js application.
  • Recognize when useState is sufficient versus when more structure is needed.
  • Use useReducer to manage state with complex, interrelated update logic.
  • Use the Context API to share state across a component tree without prop drilling.
  • Understand the performance considerations of using Context for frequently changing state.

Core Definitions

  • Local state: State that belongs to, and is only relevant within, a single component and its direct children, typically managed with useState.
  • Global state: State that needs to be read and updated from many different, potentially unrelated parts of an application's component tree.
  • useReducer: A React hook for managing state via a reducer function, which takes the current state and a dispatched action, and returns the new state — well-suited to state with multiple interrelated fields or complex update logic.
  • Context API: A built-in React feature (createContext, a Provider component, and the useContext hook) for sharing a value across a component tree without manually passing it through props at every level.
  • Prop drilling: Passing a prop down through several layers of components that don't need it themselves, purely to reach a deeply nested child that does — a problem Context is designed to solve.

How State Management Next.js Actually Works

Local state is state that only one component (and perhaps its direct children, via props) genuinely cares about — whether a specific dropdown is open, what a specific text input currently contains, whether a specific modal is visible. For this kind of state, useState remains the simplest, most direct tool, and reaching for anything more complex is usually unnecessary overhead.

But some state needs to be shared far more broadly — a logged-in user's profile information needed by a header, a sidebar, and a settings page simultaneously; a shopping cart's contents needed by a product page, a cart icon in the navbar, and a checkout page; a UI theme (light/dark mode) needed by nearly every component in the entire application. This is global state, and passing it down manually through props from a single top-level useState, layer after layer through components that don't themselves need that data, quickly becomes the prop drilling problem — tedious, error-prone, and a genuine maintenance burden as an application grows.

Before reaching for a global state solution, useReducer is worth understanding as an intermediate step, useful even for state that remains local to one component or one small part of a tree. Where useState works well for simple, independent values, useReducer shines when a piece of state has multiple related fields that update together through well-defined actions — for example, a multi-step form's state (current step, form values, validation errors) that changes together in response to specific events like 'NEXT_STEP' or 'SET_FIELD_ERROR'. Rather than several separate useState calls each updated ad hoc, a single reducer function centralizes all the update logic in one place, taking the current state and an action object, and returning the new state — making complex state transitions easier to reason about, test, and debug, since every possible state change is defined in exactly one function.

For genuinely global state, React's built-in Context API provides a solution without needing an external library. You create a context with createContext(), wrap the part of your component tree that needs access to that state in a corresponding Provider component (passing the actual state value, often combined with useState or useReducer internally), and any descendant component — no matter how deeply nested — can then read that value directly using the useContext hook, completely bypassing the need to pass it through every intermediate component's props.

A crucial performance consideration: by default, when a Context's value changes, every component consuming that context via useContext re-renders, even if that specific component only cares about a small part of the value that didn't actually change. For state that changes very frequently (like real-time cursor position) or is consumed by a very large number of components, this can become a genuine performance concern, and splitting a large context into several smaller, more focused contexts, or reaching for a dedicated external state management library (covered in the next lesson), often becomes the more scalable approach as an application's global state needs grow more complex.

Visualizing State Management Next.js

{"heading":"Visualizing State Management Next.js","description":"Visualize the decision process for choosing a state management tool:\n\n[Does only ONE component (or its direct children via props) need this state?]\n ├── YES → useState (or useReducer if the update logic is complex)\n └── NO, it's needed across many distant parts of the tree\n │\n ├── Is the update logic simple (just setting a value)?\n │ └── Context API + useState internally\n └── Is the update logic complex (multiple related fields, many action types)?\n └── Context API + useReducer internally (or an external library, next lesson)\n\nProp drilling (the problem Context solves):\n<App> --user--> <Layout> --user--> <Sidebar> --user--> <ProfileWidget>\n (Layout and Sidebar don't need 'user' themselves, just passing it through)"}

Next.js Practical Example

// contexts/CartContext.tsx — global state via Context + useReducer
'use client';

import { createContext, useContext, useReducer, ReactNode } from 'react';

type CartItem = { id: string; name: string; quantity: number };
type CartState = { items: CartItem[] };
type CartAction =
  | { type: 'ADD_ITEM'; item: CartItem }
  | { type: 'REMOVE_ITEM'; id: string };

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return { items: [...state.items, action.item] };
    case 'REMOVE_ITEM':
      return { items: state.items.filter((i) => i.id !== action.id) };
    default:
      return state;
  }
}

const CartContext = createContext<{
  state: CartState;
  dispatch: React.Dispatch<CartAction>;
} | null>(null);

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error('useCart must be used within a CartProvider');
  return context;
}

// components/CartIcon.tsx — consuming the shared state, no prop drilling
'use client';
import { useCart } from '@/contexts/CartContext';

export default function CartIcon() {
  const { state } = useCart();
  return <span>🛒 {state.items.length}</span>;
}

cartReducer centralizes every possible cart update — adding an item, removing an item — into a single, testable function, rather than scattering that logic across multiple components with individual useState calls. CartProvider combines useReducer with Context: it creates the actual state and dispatch function once, then makes both available to its entire subtree via CartContext.Provider. The custom useCart hook wraps useContext with a helpful error check, ensuring it's only used within a CartProvider. CartIcon, potentially nested many layers deep inside the app (in a navbar, itself inside a layout, itself inside the root layout), can access the cart's current item count directly via useCart(), with zero props passed down manually through any of those intermediate layers — solving the prop drilling problem that a purely useState-and-props-based approach would have created.

Real-World Examples: How Companies Use State Management Next.js

  • E-commerce sites use Context combined with useReducer for shopping cart state, since a cart needs complex, related updates (add, remove, update quantity) and must be accessible from a product page, cart icon, and checkout flow simultaneously.
  • SaaS applications use Context for authentication state (the current logged-in user), since nearly every part of an authenticated application needs to know who's logged in and what permissions they have.
  • Theme systems (light/dark mode) almost universally use Context, since the current theme needs to be readable by essentially every styled component throughout an entire application.
  • Multi-step onboarding or checkout wizards use useReducer even for state that stays local to that one flow, since the current step, collected form data, and validation state all update together through well-defined actions.
  • Internationalization (i18n) systems use Context to provide the current language/locale setting to every component that needs to render translated text, avoiding having to pass a 'locale' prop through every single component.

Common Mistakes to Avoid

  • Reaching for Context or an external state library for state that's genuinely only needed by one component, adding unnecessary complexity.
  • Continuing to prop-drill deeply nested state manually instead of recognizing it as a clear case for the Context API.
  • Using several independent useState calls for state with complex, interrelated update logic, when a single useReducer would be clearer and less error-prone.
  • Putting extremely frequently changing state (like live mouse coordinates) into a broadly-consumed Context, causing unnecessary re-renders across many components.
  • Forgetting to wrap the relevant part of the component tree in a context's Provider component, causing useContext to return an unexpected null or default value.

Interview Notes

  • Local state (useState) belongs to a single component; global state needs to be shared across many, often distant, parts of a tree.
  • useReducer centralizes complex, multi-field state update logic into a single, testable reducer function using state + action → new state.
  • The Context API (createContext, Provider, useContext) solves prop drilling by making state directly accessible to any descendant component.
  • By default, all consumers of a context re-render when its value changes, a performance consideration for frequently changing global state.
  • useReducer and Context are commonly combined: useReducer manages the state/logic, Context makes it broadly accessible.

Key Takeaways

  • Correctly distinguishing local from global state is a foundational architectural decision that shapes how maintainable an application remains as it grows.
  • useReducer isn't just for global state — it's valuable anywhere state has complex, interrelated update logic, even within a single component.
  • The Context API is React's built-in answer to prop drilling, but its default re-render-all-consumers behavior means it's not automatically the right tool for every kind of global state.
  • Understanding useState, useReducer, and Context deeply sets up the next lesson's comparison of dedicated external state management libraries for more demanding cases.

Summary

Managing state well in a Next.js application starts with correctly distinguishing local state — relevant to a single component, well-suited to useState — from global state, which needs to be read and updated across many different, often distant parts of an application. useReducer offers a more structured alternative to multiple independent useState calls when a piece of state has several related fields with complex, interrelated update logic, centralizing all possible transitions into a single, testable reducer function. For genuinely global state, React's built-in Context API solves the prop drilling problem, letting any descendant component in a wrapped subtree access shared state directly via useContext, without it being manually passed through every intermediate layer of props. useReducer and Context are frequently combined, with useReducer managing the actual state and update logic while Context makes that state broadly accessible — though it's worth remembering that, by default, every consumer of a context re-renders when its value changes, a performance consideration worth keeping in mind for very frequently changing or broadly consumed global state.