← Back to blog

React Context Is Not State Management

March 22, 2026·10 min read
ReactTypeScriptFrontend Development

React gives you two primitives for shared state out of the box: useReducer for state logic and Context for dependency injection. Together they cover a lot of ground, but they're not a state management library, and pretending they are will cost you in performance. This post walks through a production-quality implementation, explains exactly what React does under the hood when context values change, and identifies when you should reach for something else.

What Context Actually Is

Context is often described as "React's built-in state management." It isn't. Context is a dependency injection mechanism that lets you make a value available to a subtree without threading it through props. The actual state management is still done by whatever hook (useState, useReducer) lives inside the provider.

This distinction matters because of how Context triggers re-renders. When a provider's value changes (by reference), every component calling useContext on that context re-renders , even if the specific slice of state that component reads hasn't changed. There's no selector mechanism, no granular subscriptions. This is the single most important thing to understand before choosing Context for shared state.

Building a Task Manager with useReducer + Context

Let's build it properly: discriminated union types, stable references, and an awareness of the re-render implications.

The Reducer and Types

A well-typed reducer uses discriminated unions instead of payload?: any. This gives you exhaustive type checking in the switch cases and catches bugs at compile time.

import { createContext, useContext, useReducer, type ReactNode, type Dispatch } from 'react';
 
interface Task {
  id: number;
  text: string;
  completed: boolean;
}
 
interface State {
  tasks: Task[];
}
 
// Discriminated union: each action type carries exactly the payload it needs
type Action =
  | { type: 'ADD_TASK'; payload: Omit<Task, 'id'> }
  | { type: 'REMOVE_TASK'; payload: number }
  | { type: 'TOGGLE_TASK'; payload: number };
 
const initialState: State = {
  tasks: [],
};
 
let nextId = 0;
 
function taskReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'ADD_TASK':
      return {
        ...state,
        tasks: [...state.tasks, { ...action.payload, id: nextId++ }],
      };
    case 'REMOVE_TASK':
      return {
        ...state,
        tasks: state.tasks.filter(task => task.id !== action.payload),
      };
    case 'TOGGLE_TASK':
      return {
        ...state,
        tasks: state.tasks.map(task =>
          task.id === action.payload ? { ...task, completed: !task.completed } : task
        ),
      };
  }
}

A few things to note:

  • No default case. TypeScript's exhaustive checking on discriminated unions means a default case would actually hide bugs. If you add a new action type later, you want a compile error, not a silent fallthrough.
  • nextId is a module-level counter instead of Date.now(). Date.now() is technically fine for demos, but in a fast loop you can get collisions, and it makes testing nondeterministic.
  • Omit<Task, 'id'> keeps ID generation inside the reducer, which is the only place that should own that logic.

Splitting Context to Avoid Unnecessary Re-renders

Here's where most tutorials get it wrong. If you put state and dispatch in a single context value, every component that only calls dispatch (like a form that adds tasks) will still re-render whenever any task changes, because the context object reference changes on every state update.

The fix is simple: two contexts.

const TaskStateContext = createContext<State | undefined>(undefined);
const TaskDispatchContext = createContext<Dispatch<Action> | undefined>(undefined);
 
export function TaskProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(taskReducer, initialState);
 
  return (
    <TaskDispatchContext.Provider value={dispatch}>
      <TaskStateContext.Provider value={state}>
        {children}
      </TaskStateContext.Provider>
    </TaskDispatchContext.Provider>
  );
}

dispatch from useReducer has a stable identity: React guarantees it won't change between renders. By giving it its own context, components that only need to fire actions never re-render due to state changes.

export function useTaskState() {
  const context = useContext(TaskStateContext);
  if (context === undefined) {
    throw new Error('useTaskState must be used within a TaskProvider');
  }
  return context;
}
 
export function useTaskDispatch() {
  const context = useContext(TaskDispatchContext);
  if (context === undefined) {
    throw new Error('useTaskDispatch must be used within a TaskProvider');
  }
  return context;
}

The Component

import { useState } from 'react';
import { useTaskState, useTaskDispatch } from './TaskProvider';
 
export function TaskList() {
  const { tasks } = useTaskState();
  const dispatch = useTaskDispatch();
  const [taskText, setTaskText] = useState('');
 
  const addTask = () => {
    const text = taskText.trim();
    if (text) {
      dispatch({ type: 'ADD_TASK', payload: { text, completed: false } });
      setTaskText('');
    }
  };
 
  return (
    <div>
      <input
        type="text"
        value={taskText}
        onChange={(e) => setTaskText(e.target.value)}
        placeholder="Add a new task"
      />
      <button onClick={addTask}>Add Task</button>
      <ul>
        {tasks.map(task => (
          <li key={task.id}>
            <span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
              {task.text}
            </span>
            <button onClick={() => dispatch({ type: 'TOGGLE_TASK', payload: task.id })}>
              {task.completed ? 'Undo' : 'Complete'}
            </button>
            <button onClick={() => dispatch({ type: 'REMOVE_TASK', payload: task.id })}>
              Remove
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

App Entry Point

Since React 18, ReactDOM.render is deprecated and runs your app in legacy synchronous mode, opting out of concurrent features like automatic batching and transitions. Use createRoot instead:

import { createRoot } from 'react-dom/client';
import { TaskProvider } from './TaskProvider';
import { TaskList } from './TaskList';
 
function App() {
  return (
    <TaskProvider>
      <h1>Task Manager</h1>
      <TaskList />
    </TaskProvider>
  );
}
 
createRoot(document.getElementById('root')!).render(<App />);

The Re-render Problem in Detail

Even with the split-context pattern, every component reading TaskStateContext re-renders on any state change. If you have a TaskCount component that only reads tasks.length, it still re-renders when a task is toggled, because the State object reference changed.

React intentionally provides no built-in way to select a subset of context. The useContextSelector hook was proposed (and exists in some form in use-context-selector and React's internal useSyncExternalStore), but as of React 19 there's still no first-party selector API for context.

Your options for mitigating this:

  1. Split state into more contexts. If different parts of the tree need different slices, give each slice its own context. This works but gets unwieldy fast.

  2. Memoize children. Wrap child components in React.memo so they bail out of rendering when their props haven't changed. This doesn't prevent the context consumer from re-rendering, but it prevents the consumer's children from cascading.

  3. Accept it. For small-to-medium apps where state changes are infrequent (theme, auth, locale, feature flags), the re-render cost is negligible. Don't optimize what doesn't need optimizing.

When Context + useReducer Is the Right Call

This pattern works well when:

  • State updates are infrequent. Theme toggles, auth state, locale switching, feature flags.
  • The consumer count is small. A handful of components, not hundreds.
  • You want zero dependencies. No bundle size overhead, no API surface to learn.
  • State shape is simple. One or two levels deep, no derived/computed values.

When to Reach for Something Else

Context breaks down when:

  • Many consumers + frequent updates. A chat app where messages stream in, a dashboard with real-time data. Every context consumer re-renders on every update.
  • You need selectors. Components should only re-render when their specific slice of state changes. Libraries like Zustand, Jotai, and Redux Toolkit all support this.
  • You need middleware. Logging, persistence, async side effects, undo/redo. Context gives you none of this; you'd have to build it yourself.
  • You need devtools. Redux DevTools and Zustand's devtools middleware let you inspect state changes, time-travel, and replay actions. Context has no equivalent.

The right mental model: Context is for dependency injection of slowly-changing values. For actual state management at scale, purpose-built libraries exist for a reason.

Conclusion

useReducer + Context is a legitimate pattern for shared state in React, and for many applications it's all you need. But it's important to understand what you're actually getting: a dependency injection mechanism paired with a state hook, not a state management framework. Know the re-render semantics, split your contexts, type your actions properly, and you'll avoid the pitfalls that trip up most teams. When your app outgrows it, you'll know, and migrating to Zustand or Redux Toolkit from a well-structured reducer is straightforward because the reducer logic carries over directly. To see how I apply these patterns in real projects, check out my work.

Related Posts