Reminder: Context
useContext solves prop drilling: instead of passing a prop through ten
levels, a Provider offers a value and any descendant reads it. It's
perfect for data that changes little: the theme (light/dark), the language, the
authenticated user.
Where Context falls short
Context was not designed as a high-performance state manager. Its main limitation is re-renders:
When a
Provider's value changes, all the components that consume that context re-render, even if they only care about one part of the value.
If you put a large object into a single context ({ user, cart, settings, notifications }) and one property changes, all the consumers
re-render. Context has no selectors: you can't subscribe only to
cart.total and ignore the rest. In an app with state that changes often,
this translates into unnecessary renders and the hand-made solution (splitting
into many contexts, memoizing) becomes cumbersome.
That's where global state libraries come in, whose great advantage is selectors: each component subscribes only to the slice of state it uses.
Zustand
Zustand is minimalist. You create a store with create passing it a function
that defines state and actions:
import { create } from "zustand";
const useCounter = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));
The store itself is a hook. You use it with a selector to read only what you need:
function Scoreboard() {
// Re-renders ONLY if 'count' changes
const count = useCounter((s) => s.count);
return <p>Count: {count}</p>;
}
function Button() {
// This one only reads the action: it doesn't re-render when 'count' changes
const increment = useCounter((s) => s.increment);
return <button onClick={increment}>+1</button>;
}
No Provider wrapping the app is needed, there's no boilerplate and the
selector (s) => s.count avoids the re-renders Context would suffer.
Redux Toolkit
Redux is the classic of predictable global state: a single store, the state only changes by dispatching actions that pass through pure reducers. Redux Toolkit (RTK) is the official and modern way to use it, and reduces a lot of repetitive code thanks to slices:
import { configureStore, createSlice } from "@reduxjs/toolkit";
const counterSlice = createSlice({
name: "counter",
initialState: { count: 0 },
reducers: {
increment: (state) => { state.count += 1; }, // Immer: you "mutate" a draft
reset: (state) => { state.count = 0; },
},
});
export const { increment, reset } = counterSlice.actions;
export const store = configureStore({
reducer: { counter: counterSlice.reducer },
});
In components you read with useSelector (with its selector, like in Zustand)
and dispatch actions with useDispatch:
import { useSelector, useDispatch } from "react-redux";
function Scoreboard() {
const count = useSelector((s) => s.counter.count);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Count: {count}
</button>
);
}
Inside an RTK reducer it looks like you mutate the state
(state.count += 1), but under the hood it uses Immer to produce a new
immutable state safely.
Which one to choose?
- Context: global data that changes little (theme, language, session). Comes with React, no dependencies.
- Zustand: client state that changes often, with little boilerplate and selectors. An excellent middle ground.
- Redux Toolkit: large apps that benefit from a very structured flow, powerful DevTools, middlewares and team conventions.
And remember: for server data, none of these; use TanStack Query or SWR. Global state is for client state.
(Zustand and Redux are installed as dependencies; here we see them conceptually.)