Choosing State Management
"Which state management library should I use?" is one of the most common questions in React interviews and team meetings. The honest answer is that most apps need less than people think. The skill that matters is recognising what kind of state you have, because each kind has a natural home, and only a few cases need an extra library.
In this lesson you will sort state into kinds, follow a simple decision ladder, see why context alone is not a store, build a tiny external store to understand how Zustand and Redux work inside, and then read real Zustand, Redux Toolkit and older Redux code.
Five kinds of state
| Kind | Example | Natural home |
|---|---|---|
| Local UI state | Is this dropdown open? What is typed in this box? | useState in the component |
| Shared UI state | Selected city used by a filter bar and a map | Lifted to the closest common parent |
| App-wide client state | Logged-in user, theme, cart, language | Context, or an external store |
| Server state | Product list, order history, exam results | A server cache such as TanStack Query, router loaders or Server Components |
| URL state | Current page, search text, sort order | The URL (useSearchParams in React Router) |
The most common mistake in real projects is putting server state into a global store: copying the API response into Redux, then writing code by hand for loading flags, refetching and stale data. Data that lives on a server is a cache of someone else's data, and caching libraries handle it far better, as you will see in Server State with TanStack Query. Once server data and URL data have their own homes, what remains for a "store" is often small: the user, the theme, maybe a cart.
A decision ladder
Climb only as high as you need:
- Start with
useStatein the component that uses the value. - If two components need it, lift it to their common parent.
- If many update types touch the same data, use useReducer.
- If distant components need it and it changes rarely, add Context, often with a reducer.
- If it changes often, is read by many components, or must be used outside React (for example in a WebSocket handler), use an external store.
Each step adds a little complexity, so a step should solve a problem you actually have.
Why context is not a store
Context passes a value down; it does not let a component choose part of that value. When the provider's value changes, every component that reads that context re-renders, even if it only uses a field that did not change. In this example the badge only cares about the cart, yet changing the theme re-renders it:
import { createContext, useContext, useState } from "react";
import { createRoot } from "react-dom/client";
const AppContext = createContext(null);
function AppProvider({ children }) {
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
const value = { theme, setTheme, cart, setCart };
return <AppContext value={value}>{children}</AppContext>;
}
function ThemeButton() {
const { theme, setTheme } = useContext(AppContext);
console.log("ThemeButton rendered");
return <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Theme: {theme}</button>;
}
function CartBadge() {
const { cart } = useContext(AppContext);
console.log("CartBadge rendered");
return <strong> Cart ({cart.length})</strong>;
}
createRoot(document.getElementById("root")).render(
<AppProvider>
<ThemeButton />
<CartBadge />
</AppProvider>
);Loading the example and clicking the theme button once prints:
ThemeButton rendered CartBadge rendered ThemeButton rendered CartBadge rendered
With two components this does not matter. With two hundred components reading a busy context, it can. You can split the data into several contexts, but external stores solve it directly with selectors: small functions that pick out only the piece a component needs.
How an external store works
An external store keeps state outside React in a plain JavaScript object. Components subscribe to it, and React's built-in useSyncExternalStore hook (from Custom Hooks) re-renders a component only when the value its selector returns changes. Here is a complete store in about fifteen lines, the same idea Zustand is built on:
import { useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState(update) {
state = { ...state, ...update(state) }; // always a new object, never a mutation
listeners.forEach((listener) => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
const shop = createStore({ theme: "light", cart: [] });
function useShop(selector) {
return useSyncExternalStore(shop.subscribe, () => selector(shop.getState()));
}
function ThemeButton() {
const theme = useShop((s) => s.theme);
console.log("ThemeButton rendered");
return (
<button onClick={() => shop.setState((s) => ({ theme: s.theme === "light" ? "dark" : "light" }))}>
Theme: {theme}
</button>
);
}
function CartBadge() {
const count = useShop((s) => s.cart.length);
console.log("CartBadge rendered");
return <strong> Cart ({count})</strong>;
}
function AddButton() {
return <button onClick={() => shop.setState((s) => ({ cart: [...s.cart, "Masala dosa"] }))}>Add dosa</button>;
}
createRoot(document.getElementById("root")).render(
<>
<ThemeButton />
<CartBadge /> <AddButton />
</>
);Loading the example, clicking the theme button and then "Add dosa" prints:
ThemeButton rendered CartBadge rendered ThemeButton rendered CartBadge rendered
The first two lines are the first render. After that, the theme click re-renders only ThemeButton, and the cart click only CartBadge, because each selector returned the same value as before for the other component. AddButton reads nothing, so it never re-renders. Notice too that no provider is needed: any component, or any plain function, can call shop.setState.
Zustand
Zustand is a small library that packages exactly this pattern with a pleasant API. It is popular for app-wide client state in new projects:
// src/stores/cartStore.js
import { create } from "zustand";
export const useCartStore = create((set) => ({
items: [],
addItem: (name, price) => set((state) => ({ items: [...state.items, { name, price }] })),
clear: () => set({ items: [] }),
}));
// src/components/CartBadge.jsx
import { useCartStore } from "../stores/cartStore";
export function CartBadge() {
const count = useCartStore((state) => state.items.length); // selector
return <span>Cart ({count})</span>;
}
// Anywhere, even outside React:
// useCartStore.getState().clear();create returns a hook. The store holds both the data and the functions that change it, set merges the object you return into the state, and each component selects what it needs.
Redux Toolkit
Redux is the oldest and most widely used store, and you will find it in many company codebases. Modern Redux is written with Redux Toolkit (@reduxjs/toolkit) plus react-redux. A slice groups the state, the reducers and the generated action creators for one feature:
// src/features/cart/cartSlice.js
import { createSlice } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
itemAdded(state, action) {
state.items.push(action.payload); // looks like mutation, but Immer makes a new state
},
cleared(state) {
state.items = [];
},
},
});
export const { itemAdded, cleared } = cartSlice.actions;
export default cartSlice.reducer;
// src/app/store.js
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";
export const store = configureStore({ reducer: { cart: cartReducer } });
// main.jsx: <Provider store={store}><App /></Provider> (Provider from "react-redux")
// In a component:
// const count = useSelector((state) => state.cart.items.length);
// const dispatch = useDispatch();
// dispatch(itemAdded({ name: "Vada pav", price: 25 }));Inside createSlice, reducers may write state.items.push(...), because Redux Toolkit uses the Immer library to turn those changes into a new state object. This is the only place where such code is safe; in useState and useReducer it would still be a bug. configureStore also connects the Redux DevTools browser extension, which shows every action and lets you step back through state changes. Redux Toolkit also includes RTK Query, a server cache similar to TanStack Query.
Legacy Redux you will meet at work
Code written before Redux Toolkit looks much longer. Recognise these pieces:
// Action type constants and action creators
const ADD_ITEM = "cart/ADD_ITEM";
const addItem = (item) => ({ type: ADD_ITEM, payload: item });
// A hand-written reducer that must copy everything with spread
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case ADD_ITEM:
return { ...state, items: [...state.items, action.payload] };
default:
return state;
}
}
const store = createStore(combineReducers({ cart: cartReducer }));
// connect() injects store data and actions as props (a higher-order component)
const mapStateToProps = (state) => ({ count: state.cart.items.length });
export default connect(mapStateToProps, { addItem })(CartBadge);createStore has been marked deprecated since Redux 4.2 in favour of Redux Toolkit's configureStore; it still works and you may also see it imported as legacy_createStore. connect still works too, but the React Redux docs recommend the useSelector and useDispatch hooks for new code. When you maintain such a project, new features can be written as slices next to the old reducers, and old files converted one at a time.
Common mistakes
- Putting server data in a global store. Use a server cache; keep the store for client state.
- Reaching for a library on day one. Start with local state and climb the ladder only when it hurts.
- One giant context for everything. Every reader re-renders on every change. Split it or use a store with selectors.
- Selectors that build new objects. They cause extra renders or loops; select single values.
- Mutating outside Immer.
state.items.push()is fine inside a Redux Toolkit slice and a bug everywhere else.
Key points
- Classify state first: local UI, shared UI, app-wide client, server and URL state each have a natural home.
- Climb the ladder:
useState, lifting,useReducer, context, and only then an external store. - Context re-renders every reader when its value changes; stores use selectors to re-render only what changed.
- External stores live outside React and connect through
useSyncExternalStore, no provider required. - Zustand offers a small hook-based store; Redux Toolkit offers slices, DevTools and RTK Query.
- Legacy Redux uses hand-written reducers,
createStoreandconnect; migrate gradually to slices and hooks.
Exercise
Using the createStore function from this lesson, build a canteen token display. The store holds nowServing (a number starting at 41) and waiting (an array of names). Create three components: NowServing shows the token number, QueueLength shows how many people are waiting, and Controls has buttons "Join queue" (adds a name such as "Guest 1", "Guest 2"...) and "Serve next" (increases nowServing and removes the first name). Log a line in each display component so you can confirm that each one re-renders only when its own value changes.
Show solution
import { useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
return {
getState: () => state,
setState(update) {
state = { ...state, ...update(state) };
listeners.forEach((l) => l());
},
subscribe(l) {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
const canteen = createStore({ nowServing: 41, waiting: [], joined: 0 });
const useCanteen = (selector) => useSyncExternalStore(canteen.subscribe, () => selector(canteen.getState()));
function NowServing() {
const token = useCanteen((s) => s.nowServing);
console.log("NowServing rendered");
return <h2>Now serving: {token}</h2>;
}
function QueueLength() {
const count = useCanteen((s) => s.waiting.length);
console.log("QueueLength rendered");
return <p>{count} waiting</p>;
}
function Controls() {
const join = () =>
canteen.setState((s) => ({ joined: s.joined + 1, waiting: [...s.waiting, `Guest ${s.joined + 1}`] }));
const serve = () =>
canteen.setState((s) => (s.waiting.length ? { nowServing: s.nowServing + 1, waiting: s.waiting.slice(1) } : {}));
return (
<>
<button onClick={join}>Join queue</button> <button onClick={serve}>Serve next</button>
</>
);
}
createRoot(document.getElementById("root")).render(
<>
<NowServing />
<QueueLength />
<Controls />
</>
);"Join queue" changes only the queue length, so only QueueLength logs. "Serve next" changes both values, so both log. A separate joined counter keeps guest names unique even after people are served.
Quick Quiz
1. Where should a product list loaded from your API usually live?
Server data is a cache of the server's data; caching libraries handle loading, refetching and staleness.
2. Why can a busy context slow an app down?
Context has no selectors, so readers cannot subscribe to just one field.
3. What does a selector such as (s) => s.cart.length do in a store hook?
The hook compares the selected value between updates and skips re-rendering if it is unchanged.
4. Why is state.items.push(item) acceptable inside a Redux Toolkit createSlice reducer?
The "mutating" syntax is converted into a safe immutable update; outside Immer it would be a bug.
Learn React with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
