Type something to search...
Context API vs Redux: Which One Should You Use?

Context API vs Redux: Which One Should You Use?

"Do I still need Redux now that we have Context?" is one of the most common questions in React. You'll find confident answers on both sides: Context is all you need, or Context doesn't scale and you should always use Redux. Both answers are wrong in general and right in specific situations, which is why the question keeps coming up.

The confusion starts with the fact that Context and Redux aren't really the same kind of tool. Context is a dependency injection mechanism: it passes a value down the tree. Redux is a state management library: it holds state in a store, defines how it changes, and lets components subscribe to slices of it. They overlap because Context combined with useReducer can act like a small store.

This post builds the same feature with both approaches, compares them on the things that actually matter in practice (re-renders, boilerplate, debugging, async logic, and team scale), and ends with a simple decision guide.

What Each One Actually Does

Context

Context lets a provider make a value available to every component below it. It doesn't store anything itself. The state still lives in a component, usually in useState or useReducer, and Context is just the delivery mechanism.

When the provided value changes, every component that reads that context re-renders. There's no built-in way to subscribe to only part of the value.

Redux (Redux Toolkit)

Redux keeps state in a single store outside React. You change it by dispatching actions, which are handled by pure reducer functions. Components read state through selectors, and a component only re-renders when the value its selector returns changes.

Modern Redux means Redux Toolkit (RTK). It removes the boilerplate that gave classic Redux its reputation: no hand-written action types, no switch statements, and immutable updates written with plain mutation syntax thanks to Immer. If you've only seen pre-2020 Redux, it's worth a fresh look.

The Same Feature, Built Twice

Let's build a small notifications feature: add a notification, dismiss one, and show an unread count in the header.

Version 1: Context + useReducer

// notifications-context.tsx
import { createContext, useContext, useReducer, type Dispatch, type ReactNode } from "react";

export type Notification = { id: string; message: string; read: boolean };

type State = { items: Notification[] };
type Action =
  | { type: "added"; message: string }
  | { type: "dismissed"; id: string }
  | { type: "allRead" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "added":
      return {
        items: [...state.items, { id: crypto.randomUUID(), message: action.message, read: false }],
      };
    case "dismissed":
      return { items: state.items.filter((n) => n.id !== action.id) };
    case "allRead":
      return { items: state.items.map((n) => ({ ...n, read: true })) };
  }
}

const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);

export function NotificationsProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, { items: [] });
  return (
    <StateContext value={state}>
      <DispatchContext value={dispatch}>{children}</DispatchContext>
    </StateContext>
  );
}

export function useNotifications() {
  const ctx = useContext(StateContext);
  if (!ctx) throw new Error("Missing NotificationsProvider");
  return ctx;
}

export function useNotificationsDispatch() {
  const ctx = useContext(DispatchContext);
  if (!ctx) throw new Error("Missing NotificationsProvider");
  return ctx;
}
// UnreadBadge.tsx
import { useNotifications } from "./notifications-context";

export function UnreadBadge() {
  const { items } = useNotifications();
  const unread = items.filter((n) => !n.read).length;
  return <span>{unread} unread</span>;
}

That's around 50 lines, no dependencies, and fully typed. State and dispatch are split so components that only dispatch don't re-render on changes. The details of this pattern are covered in avoiding prop drilling with the React Context API.

Version 2: Redux Toolkit

npm install @reduxjs/toolkit react-redux
// notificationsSlice.ts
import { createSlice, nanoid, type PayloadAction } from "@reduxjs/toolkit";

export type Notification = { id: string; message: string; read: boolean };

const notificationsSlice = createSlice({
  name: "notifications",
  initialState: { items: [] as Notification[] },
  reducers: {
    added: {
      reducer(state, action: PayloadAction<Notification>) {
        state.items.push(action.payload);
      },
      prepare(message: string) {
        return { payload: { id: nanoid(), message, read: false } };
      },
    },
    dismissed(state, action: PayloadAction<string>) {
      state.items = state.items.filter((n) => n.id !== action.payload);
    },
    allRead(state) {
      state.items.forEach((n) => {
        n.read = true;
      });
    },
  },
  selectors: {
    selectUnreadCount: (state) => state.items.filter((n) => !n.read).length,
  },
});

export const { added, dismissed, allRead } = notificationsSlice.actions;
export const { selectUnreadCount } = notificationsSlice.selectors;
export default notificationsSlice.reducer;
// store.ts
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import notifications from "./notificationsSlice";

export const store = configureStore({
  reducer: { notifications },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
// UnreadBadge.tsx
import { useAppSelector } from "./store";
import { selectUnreadCount } from "./notificationsSlice";

export function UnreadBadge() {
  const unread = useAppSelector(selectUnreadCount);
  return <span>{unread} unread</span>;
}

And wrap the app with <Provider store={store}> from react-redux.

The line counts are similar. The real differences are in what you get on top.

Comparison: Where They Actually Differ

ConcernContext + useReducerRedux Toolkit
DependenciesNone@reduxjs/toolkit, react-redux
Re-render granularityEvery consumer of a context re-renders on any changeComponents re-render only when their selected value changes
DebuggingReact DevTools shows the valueRedux DevTools: action log, state diffs, time travel
Async logicWrite it yourself in components or hookscreateAsyncThunk, listener middleware, RTK Query
Immutable updatesManual spreadsImmer, write mutating syntax safely
ScopePer subtree, many providersOne global store
Learning curveSmallModerate

Re-Renders: The Biggest Practical Difference

With Context, the unit of subscription is the whole context value. In the notifications example, UnreadBadge re-renders whenever any notification changes, even if the unread count stays the same (for example when dismissing a read notification). For a badge that's trivial. For a context holding a large, frequently changing object read by dozens of components, it adds up.

With Redux, useAppSelector(selectUnreadCount) re-renders only when the returned number changes. Each component subscribes to precisely what it uses. You can approximate that with Context by splitting into many small contexts, but it gets awkward as state grows.

This is the core technical reason Context isn't a full replacement for a store. It's not "slow"; it simply wasn't designed for fine-grained subscriptions.

Debugging and Predictability

Redux DevTools records every action with the state before and after. When a bug report says "the cart total was wrong after removing an item," you can replay exactly what happened. On larger teams, the strict "all changes go through actions and reducers" rule makes data flow easy to follow, even in code you didn't write.

Context with useReducer gives you the same reducer discipline, but without the global action log.

Async Logic and Server Data

Redux Toolkit includes createAsyncThunk for async flows and RTK Query for data fetching and caching. With Context, you'll write fetch logic in effects or custom hooks, or bring in another library.

That said, much of what people historically put in Redux was server data: lists of users, products, and posts fetched from an API. Today, a dedicated server-state library like TanStack Query or RTK Query handles caching, deduplication, and refetching far better than hand-written reducers. Once server data moves out, the remaining client state is often small enough for Context.

When Context Is the Right Choice

  • Low-frequency, app-wide values: current user, theme, locale, feature flags, permissions. These change rarely, so re-rendering all consumers is fine.
  • Scoped state for a subtree: a wizard, a compound component like tabs, or a form section. Context can be provided anywhere, and the state is thrown away when the subtree unmounts.
  • Dependency injection: passing services like an analytics client or an API client, which never change after setup.
  • Small to medium apps where shared client state is modest and server state is handled by a data library.

When Redux Is the Right Choice

  • Lots of shared, frequently updated client state read by many components, like a complex editor, a dashboard with interdependent filters, or a canvas app.
  • You need strong debugging and traceability: action logs, time travel, and reproducible state.
  • Large teams that benefit from a single, enforced pattern for how state changes.
  • Complex async workflows coordinated across features, where middleware and listeners shine.
  • You're already using RTK Query and want cached server data and client state in one store.

If you decide on Redux, getting started with Redux Toolkit walks through the setup from scratch.

Don't Forget the Middle Ground

It's not a binary choice. Lightweight store libraries give you selector-based subscriptions like Redux with almost no setup. Zustand is a popular example:

import { create } from "zustand";

type NotificationsStore = {
  items: { id: string; message: string; read: boolean }[];
  add: (message: string) => void;
};

export const useNotificationsStore = create<NotificationsStore>()((set) => ({
  items: [],
  add: (message) =>
    set((s) => ({ items: [...s.items, { id: crypto.randomUUID(), message, read: false }] })),
}));

// In a component: re-renders only when the unread count changes
// const unread = useNotificationsStore((s) => s.items.filter((n) => !n.read).length);

And many apps mix approaches comfortably: TanStack Query for server data, Context for theme and auth, and local useState for everything else. You can also use Context and Redux together. Redux itself uses Context internally to make the store available to useSelector.

A Simple Decision Guide

Ask these questions in order:

  1. Is it server data? Use TanStack Query or RTK Query, not Context or hand-written reducers.
  2. Is it only used by one component or a few nearby ones? Keep it in local state, or lift it to a common parent.
  3. Does it change rarely and need to reach many components? Use Context.
  4. Does it change often, get read by many components, and need fine-grained updates or strong debugging? Use Redux Toolkit or a lightweight store like Zustand.

Common Mistakes When Choosing

  • Using Redux for everything by default. Form input values and toggle states that one component uses don't belong in a global store.
  • Building a homemade Redux on Context. If you find yourself writing selector systems, middleware, and memoization layers on top of Context, use a library that already solved those problems.
  • Putting server data in either one by hand. Caching, refetching, and invalidation are hard. Use a server-state library.
  • Judging Redux by its old boilerplate. Redux Toolkit removed most of it. Compare against RTK, not 2017-era Redux.
  • One giant context. If you choose Context, split it by domain and update frequency to avoid unnecessary re-renders.

Frequently Asked Questions (FAQ) About Context API vs Redux

Yes, especially for large apps with complex shared client state and teams that value strict patterns and great debugging tools. It's less of a default than it once was, because server-state libraries and lighter stores cover many cases that used to need Redux.

Not by itself. Context passes a value down the tree. The state lives in useState or useReducer in the provider component. Together they form a simple state management solution, but Context alone only handles delivery.

Context isn't slow, but it re-renders every consumer when its value changes. Redux re-renders only components whose selected values changed. For frequently updated state read by many components, that difference can matter.

Yes, and it's common. Use Redux for complex shared state and Context for things like theme, locale, or scoped component state. They don't conflict.

Learn React's built-in tools first: useState, useReducer, lifting state, and Context. Once you understand those, Redux Toolkit is easier to learn, and you'll understand which problems it solves.

They're good choices when you want selector-based updates without Redux's structure. Zustand is a small store with hooks, and Jotai uses atoms. Redux Toolkit offers more built-in tooling and conventions, which can help larger teams.

Conclusion

Context and Redux solve different problems. Context delivers values through the tree and works best for state that changes rarely or is scoped to a subtree. Redux Toolkit is a full store with selector-based subscriptions, DevTools, and async tooling, and it pays off when you have a lot of frequently changing shared state or a large team.

Start with local state and Context, move server data into a dedicated data library, and reach for Redux Toolkit or a lighter store when you start fighting re-renders or need better visibility into how state changes. Choosing based on the shape of your state, not on habit, is what keeps an app simple.

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