EthicsComputerCourses
Join training +91 90532 10052

Choosing State Management

React · Lesson 22 of 41 Intermediate 8 min read Updated

"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

KindExampleNatural home
Local UI stateIs this dropdown open? What is typed in this box?useState in the component
Shared UI stateSelected city used by a filter bar and a mapLifted to the closest common parent
App-wide client stateLogged-in user, theme, cart, languageContext, or an external store
Server stateProduct list, order history, exam resultsA server cache such as TanStack Query, router loaders or Server Components
URL stateCurrent page, search text, sort orderThe 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:

  1. Start with useState in the component that uses the value.
  2. If two components need it, lift it to their common parent.
  3. If many update types touch the same data, use useReducer.
  4. If distant components need it and it changes rarely, add Context, often with a reducer.
  5. 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:

React JSXTry it yourself
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:

Output
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:

React JSXTry it yourself
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:

Output
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:

React JSX
// 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:

React JSX
// 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:

React JSX
// 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, createStore and connect; 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
React JSX
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?

2. Why can a busy context slow an app down?

3. What does a selector such as (s) => s.cart.length do in a store hook?

4. Why is state.items.push(item) acceptable inside a Redux Toolkit createSlice reducer?

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