cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

State management libraries

When React's built-in state is enough, when it isn't, and what Zustand, Redux Toolkit and Jotai each bring, compared on the same live cart with render counters.

By the end, you can
  • 01
    Sort an app's stateTell one component's state, shared state, URL state, and server state apart, and give each the tool it needs.
  • 02
    See when context isn't enoughWatch every reader of a context render on every change, and a store with selectors render only the readers that need it.
  • 03
    Compare Zustand, Redux Toolkit and JotaiRead and change the same cart with each, and choose one, or none, for an app.

One cart that half the shop needs

A shop's cart shows up all over the page: a count in the header, Add buttons in the product list, a gift note, and a total. Here is that cart with React's own tools: state in a CartProvider, shared through a context, as in Scaling up with a reducer and context. Each box has a small counter from the RenderCount.jsx tab, as in When React re-renders: it shows how many times React rendered that component.

A cart shared through contextPop out in the code editor (opens in a new tab)JSX
import { createContext, useContext, useState } from "react";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const CartContext = createContext(null); function CartProvider({ children }) {  const [items, setItems] = useState([]);  const [note, setNote] = useState("");  const add = (tea) => setItems([...items, tea]);  return (    <CartContext value={{ items, note, add, setNote }}>      {children}    </CartContext>  );} function CartBadge() {  const count = useContext(CartContext).items.length;  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const { add } = useContext(CartContext);  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const { note, setNote } = useContext(CartContext);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const { items } = useContext(CartContext);  const total = items.reduce((sum, tea) => sum + tea.price, 0);  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <CartProvider>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </CartProvider>  );}
ResultLoading…

CartProvider on lines 12 to 21 keeps the items and the note in state (lines 13 and 14), and line 17 passes them, with the add and setNote functions, as one value. Each of the four components reads what it needs with useContext: the badge counts the items on line 24, and the total adds up their prices on line 52.

Click Add Green tea and Add Chai. The badge says Cart: 2, the total Total: $8, and they agree. React's built-in tools did the job. Now type Hi in the gift note and watch the counters: all four go up with each letter, even the badge and the total, which show nothing about the note. That's the question of this lesson: when is React's own state enough, and when does a state management library help?

Before you can answer that, it helps to see that an app keeps several kinds of state, and each has its own best home. Only one of them is a job for a state library.

A shop page with four pieces of state. Menu open, true, lives in component state. sort equals price lives in the URL. The teas from the API live in a data library's cache. The cart, 2 teas, lives in a shared store.shop pagemenu open: truecomponent state?sort=pricethe URLteas from the APIa data library's cachecart: 2 teasa shared store
One page, four homes. A menu's open flag stays in its component, the sort order in the URL, the server's teas in a data library's cache, and the cart in a shared store. A state management library is only for the last one.
Real-life analogyAn office's shared information

In an office, most notes stay on one desk. The room number is on the door for anyone to use. Official records live with the department that owns them. Shared news goes on a noticeboard, and every change makes the whole room stop and look. A busy office swaps the board for alerts: each person signs up for the topics they care about, and only hears about those.

In real life: A note on your own desk
In React: useState in one component
In real life: The room number on the door, which visitors use to find you
In React: The URL
In real life: The company's records, kept by another department
In React: Server state, in a data library's cache
In real life: A noticeboard where any change makes everyone look up
In React: A context: every reader renders
In real life: Alerts that each person signs up for, about their own topics
In React: A store with selectors: only readers whose part changed render
The main idea

A state management library keeps shared client state in a store outside your components. Each component subscribes to just the part it reads, and only renders when that part changes. Most state doesn't need one: keep it in its component, in the URL, or in a data library's cache, and use a library for the shared client state that's left.

Sort your state before you pick a tool

Client state is data that only this page has, like what you've typed or what's in your cart before checkout. Server state is a copy of data a server owns, like products and stock: it needs loading, can go out of date, and other people change it. The Data fetching libraries lesson showed why server state belongs in a cache like TanStack Query's. The address is a third kind, and Routing showed how it decides the page and keeps options like a sort order.

How to read the table: each row is one kind of state, an example from the shop, and the tool that fits it.

Kinds of state, and where each one lives
Kind of stateIn the shopWhere it lives
One component's stateWhether a menu is open. The text in a box as you type.useState or useReducer, in that component.
State a few nearby components shareA filter that a list and its count both read.Lifted to their closest common parent, and passed down as props.
Values many far-apart components readThe signed-in user. The theme.Context, with a reducer when the updates get complex.
Where you are, and the page's options/teas/mint. ?sort=price.The URL, through your router.
A copy of the server's dataProducts, stock, past orders.A data library's cache, such as TanStack Query's.
Shared client state that changes oftenA cart before checkout. A comparison tray.A store: your own, read with useSyncExternalStore, or a library's.

Read the table from the top: each row is a step to take only when the one above it stops fitting. Most state is one component's, and useState is all it needs. When two components need it, lift it up. When many components far apart need it, use a context. When updates get complicated, add a reducer.

Large older React apps often put everything in one global store, including the server's data, with code to fetch it, save it, and track loading. Once server data moves to a data library and the page's options move to the URL, the shared client state that's left is usually small: a cart, a few settings, open panels. Start there, and the question becomes "is context enough for this?"

When context isn't enough

Go back to the context cart and type in the gift note. Every key renders all four components. A context has one value: here, the object on line 17, made new on each render of CartProvider. When it changes, React renders every component that reads the context. It doesn't look at which part of the value each one used.

Two halves, after typing one letter in the gift note. Left, with context: CartContext has arrows to CartBadge, ProductList, GiftNote, and CartTotal, and all four render. Right, with a store and selectors: only GiftNote renders; CartBadge, ProductList, and CartTotal are faded, skipped.with contextCartContextCartBadgeProductListGiftNoteCartTotalall four renderwith a store and selectorscartStoreCartBadgeProductListGiftNoteCartTotalonly GiftNote renders
You type one letter. Left: the context's value is a new object, so all four readers render. Right: the store runs each selector, and only GiftNote's answer, the note, changed.

For a small page that's fine: rendering four small components is quick. It starts to cost when many components read a context that changes often, like a large cart page where each of 50 rows reads the cart and you type in a note. Within React, you can:

  • Split the context. Put the functions in a context of their own, as the reducer-and-context lesson did with dispatch, and give the note a context too. Each new piece of state needs another context and another provider.
  • Memoize below the readers. A component that reads the context still renders, but memo can stop the components under it from rendering.
  • Use a store with selectors. A store holds the state outside React. A selector is a small function that picks the part a component needs, like (state) => state.items.length. The component renders only when its selector's answer changes.

The last one is the store you built in Subscribing to external stores. Here is the same cart on that store.js, unchanged (open its tab):

The same cart on a store with selectorsPop out in the code editor (opens in a new tab)JSX
import { createStore, useStore } from "./store.js";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const cartStore = createStore({ items: [], note: "" }); function add(tea) {  const state = cartStore.getState();  cartStore.setState({ ...state, items: [...state.items, tea] });} function setNote(note) {  cartStore.setState({ ...cartStore.getState(), note });} function CartBadge() {  const count = useStore(cartStore, (state) => state.items.length);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  // Reads nothing from the store, so the store never renders it.  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const note = useStore(cartStore, (state) => state.note);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useStore(cartStore, (state) =>    state.items.reduce((sum, tea) => sum + tea.price, 0),  );  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </>  );}
ResultLoading…

Line 10 makes one store for the cart, outside any component. add and setNote on lines 12 to 19 are plain functions that make a new state and call setState. Each component reads its part with a selector: the count on line 22, the note on line 39, the total on lines 49 to 51. ProductList reads nothing at all: it only calls add.

Type Hi in the gift note: only GiftNote's counter goes up. Click Add Green tea: the badge and the total go up, and the list and the note stay. useSyncExternalStore runs each selector after every change and renders a component only if its answer is different. Try both on the two versions side by side:

Playground: who renders?
With context4 readers
Cart: 0
ProductList
Note: (empty)
Total: $0
With a store and selectors4 selectors
Cart: 0
ProductList
Note: (empty)
Total: $0
Try it yourself

Press Type a letter in the note, then Add Green tea, and compare the counters on both sides.

Both sides run in this page with React. Each box counts the renders React committed for it, as RenderCount.jsx does in the examples. The left reads one context; the right reads a store with a selector per box. Reset starts both over.

Rendering too much is one reason to reach for a store. These are others, and any one of them can be enough:

  • Code outside React changes the state. A websocket message, a timer, or a checkout function that isn't a component can change a store directly; a context can only change inside React.
  • Many pieces of state depend on each other, and you'd like the rules in one place, with tools to see each change.
  • The state should be saved to the browser and come back, or be shared between tabs.
  • A team wants one way to write shared state across a big app.

Your own store covers the first reason in 23 lines. Libraries add the rest, tested by many apps. Here are three popular ones, with the same cart.

Zustand: one store and a hook

Zustand (German for "state", version 5.0.16 here) is a small library that gives you a store like yours, and a hook to read it with selectors. The versions in this lesson were the latest in October 2026. In a project you install each library with npm:

Install the three libraries in a projectShell
npm install zustand
npm install @reduxjs/toolkit react-redux
npm install jotai
The cart on ZustandPop out in the code editor (opens in a new tab)JSX
import { create } from "zustand@5.0.16?dev&deps=react@19.3.1,react-dom@19.3.1";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const useCart = create((set) => ({  items: [],  note: "",  add: (tea) => set((state) => ({ items: [...state.items, tea] })),  setNote: (note) => set({ note }),})); function CartBadge() {  const count = useCart((state) => state.items.length);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const add = useCart((state) => state.add);  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const note = useCart((state) => state.note);  const setNote = useCart((state) => state.setNote);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useCart((state) =>    state.items.reduce((sum, tea) => sum + tea.price, 0),  );  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </>  );}
ResultLoading…

Lines 10 to 15 are the whole store. create takes a function that gets set and returns the starting state: the data (items, note) and the functions that change it (add, setNote), in one object. set merges what you give it into the state, so line 14 changes only note. Line 13 passes set a function, to build the new items from the current state, like an updater function in useState.

create returns a hook, useCart. Each component calls it with a selector: the count on line 18, the add function on line 23, the note and setNote on lines 35 and 36. There's no provider: the store lives in the module, like cartStore did. Type in the note and add teas: the counters move exactly as they did with your own store, because Zustand's hook is built on useSyncExternalStore too.

The import on line 1 names a version and adds ?dev&deps=react@19.3.1,react-dom@19.3.1, so the live result loads the library's development build, made for the same React the result uses. In a project you write from "zustand".

One rule matters with every selector, in all three libraries: return a value that stays the same while the state does. A selector that builds a new object every time gives a "new" answer on every read, and React renders again forever:

An object from a selector, and useShallowJSX
import { useShallow } from "zustand/react/shallow";

// A new object every time: React sees a change on every read.
const { count, note } = useCart((state) => ({ count: state.items.length, note: state.note }));

// useShallow compares the object's values instead, so equal values count as no change.
const { count, note } = useCart(
  useShallow((state) => ({ count: state.items.length, note: state.note })),
);

Line 4 makes a new { count, note } object on each call, so React warns The result of getSnapshot should be cached to avoid an infinite loop and stops with Maximum update depth exceeded. Lines 7 to 9 wrap the same selector in useShallow, which compares the object's values instead of the object, so equal values count as no change. Or call the hook twice, once per value, as GiftNote does.

Redux Toolkit: actions, reducers, and one store

Redux is the oldest of the three, and the one you'll meet in many existing apps. Its rule: state changes only through actions, plain objects that say what happened, and a reducer turns the current state and an action into the next state. That's the pattern from Extracting state logic into a reducer, for a whole app; the JavaScript course's State management lesson builds it without React. Redux Toolkit (2.13.0) is the official way to write Redux today, and React Redux (9.3.0) connects it to components.

The cart on Redux ToolkitPop out in the code editor (opens in a new tab)JSX
import { configureStore, createSlice } from "@reduxjs/toolkit@2.13.0?dev&deps=react@19.3.1,react-dom@19.3.1";import { Provider, useDispatch, useSelector } from "react-redux@9.3.0?dev&deps=react@19.3.1,react-dom@19.3.1";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const cartSlice = createSlice({  name: "cart",  initialState: { items: [], note: "" },  reducers: {    added(state, action) {      state.items.push(action.payload);    },    noteChanged(state, action) {      state.note = action.payload;    },  },});const { added, noteChanged } = cartSlice.actions;const store = configureStore({ reducer: { cart: cartSlice.reducer } }); function CartBadge() {  const count = useSelector((state) => state.cart.items.length);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const dispatch = useDispatch();  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => dispatch(added(tea))}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const note = useSelector((state) => state.cart.note);  const dispatch = useDispatch();  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => dispatch(noteChanged(event.target.value))} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useSelector((state) =>    state.cart.items.reduce((sum, tea) => sum + tea.price, 0),  );  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <Provider store={store}>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </Provider>  );}
ResultLoading…

createSlice on lines 11 to 22 makes a slice: one part of the store, with its name, its starting state, and its reducers. Each reducer on lines 15 to 20 handles one action. Line 16 pushes into state.items, which looks like changing state in place, which React state forbids. It's safe here because createSlice runs reducers with Immer: state is a draft, and Immer turns your changes into a new state object, as in Updating objects in state.

Line 23 takes the action creators, functions that build actions, from the slice. Line 24 makes the store with configureStore, and line 63 gives it to every component with <Provider store={store}>. Components read with useSelector (lines 27, 44, 55) and change things with dispatch from useDispatch: line 36 dispatches added(tea). Here is what that action looks like:

What added(tea) makesJavaScript
console.log(added({ id: "green", name: "Green tea", price: 3 }));
// { type: "cart/added", payload: { id: "green", name: "Green tea", price: 3 } }

Line 1 calls the action creator, and line 2 is what it returns: a type, made from the slice's name and the reducer's, and a payload, the tea. Every change in the app takes the same road:

Left to right: a click dispatches the action cart/added. An arrow goes to the reducer, which makes a new state. An arrow goes to the store, which keeps it. An arrow goes to CartBadge and CartTotal, which render.cart/added1. an actionreducer2. the new statestore3. kept hereCartBadgeCartTotal4. render
Every change takes the same road. A component dispatches an action, a plain object that says what happened. The reducer makes the new state. The store keeps it and tells useSelector, which renders the components whose answer changed. The next click starts the road again.

Try the counters: they move exactly as with Zustand. Redux costs more code: 14 lines set up the cart here, against 6 in Zustand, plus the Provider. What the code buys is one strict pattern. Every change is an action you can log, so the Redux DevTools browser extension can list every action and step back through them ("time travel"). Redux Toolkit also includes RTK Query, a data library like TanStack Query, for server state.

Jotai: state in small atoms

Jotai (Japanese for "state", 3.0.1 here) starts from the other end. Instead of one store object, you make many small pieces of state called atoms, and read each one like a useState that any component can share.

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai@3.0.1?dev&deps=react@19.3.1,react-dom@19.3.1";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const itemsAtom = atom([]);const noteAtom = atom("");const countAtom = atom((get) => get(itemsAtom).length);const totalAtom = atom((get) =>  get(itemsAtom).reduce((sum, tea) => sum + tea.price, 0),); function CartBadge() {  const count = useAtomValue(countAtom);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const setItems = useSetAtom(itemsAtom);  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => setItems((items) => [...items, tea])}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const [note, setNote] = useAtom(noteAtom);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useAtomValue(totalAtom);  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </>  );}
ResultLoading…

Lines 10 and 11 make two atoms with starting values: the items and the note. Lines 12 to 15 make two derived atoms: an atom made with a function works its value out from other atoms, read with get. Jotai remembers what each one read, so when itemsAtom changes, it works countAtom and totalAtom out again.

useAtomValue reads an atom (lines 18 and 45), useSetAtom gives only its setter (line 23), and useAtom gives both, like useState (line 35). Line 27 sets the items with an updater function. There's no provider: atoms live in a default store until you add a <Provider> for a separate one. The counters move exactly as before: a component renders only when an atom it reads has a new value.

Choosing a library, or none

All three fixed the same problem the same way: components read a part of the state, and render only when that part changes. They differ in the shape they give your state:

Three columns. Zustand: one store, useCart, holding items, note, add, and setNote. Redux Toolkit: an action goes into the cart slice's reducer inside one store. Jotai: two atoms, itemsAtom and noteAtom, and two derived atoms, countAtom and totalAtom, worked out from itemsAtom.ZustanduseCartitemsnoteadd()setNote()Redux Toolkitadded(tea)storecart reducer{ items, note }JotaiitemsAtomnoteAtomcountAtomtotalAtomderived from itemsAtom
The same cart, shaped three ways. Zustand keeps the data and its functions in one object. Redux Toolkit changes it only through actions and a reducer. Jotai splits it into atoms, and works values out from them.

How to read the table: each row asks one question about the same cart, in each library.

The same cart in three libraries
QuestionZustandRedux ToolkitJotai
How is the state shaped?One store object, with its functions inside.One store made of slices, each with a reducer.Many small atoms. Derived atoms work values out from others.
How does a component read it?useCart(selector).useSelector(selector).useAtomValue(atom), or useAtom(atom).
How does it change?Call a store function, which calls set.dispatch an action, and the slice's reducer makes the new state.Set an atom with useSetAtom or useAtom.
Does it need a provider?No.Yes: <Provider store={store}>.No. A <Provider> is optional, for a separate set of atoms.
Lines that set up this cart6.14, plus the Provider.6.
Who renders when the note changes?GiftNote only.GiftNote only.GiftNote only.
What else comes with it?Middleware such as persist and devtools.Redux DevTools with time travel, middleware, and RTK Query for server data.Utilities such as atomWithStorage and selectAtom, in jotai/utils.
When does it fit best?You want one small store with little code.A big app and team want one strict pattern, and its tools.The state is many small pieces, with values worked out from others.

A short way to choose:

  1. Sort the state first. Server data goes to a data library, the page's options go to the URL, and one component's state stays there.
  2. Try React's tools for what's left. Lifted state and context are enough when few components read the value or it rarely changes, like a signed-in user or a theme.
  3. Pick a library when the signs show up: many readers of state that changes often, changes from outside React, or state you want saved. Zustand when you want one small store, Redux Toolkit when a team wants one strict pattern and its tools, Jotai when the state is many small pieces with values worked out from others.
  4. In an existing app, use what it has. Two state libraries in one app are harder to follow than either one.

Zustand and Jotai are small libraries; Redux Toolkit with React Redux is bigger, and brings more. All three support React 19 and TypeScript.

A cart that survives a refresh

Two of the reasons for a store show up in almost every shop: the cart should still be there after a refresh, and code that isn't a component should be able to change it. Here is a cart on Zustand that does both, with its persist middleware. Middleware wraps a store to add a feature; persist saves the state to localStorage on every change and reads it back when the store is made.

A cart saved in localStorage, emptied by plain JavaScriptPop out in the code editor (opens in a new tab)JSX
import { create } from "zustand@5.0.16?dev&deps=react@19.3.1,react-dom@19.3.1";import { persist } from "zustand@5.0.16/middleware?dev&deps=react@19.3.1,react-dom@19.3.1";import { sendOrder } from "./api.js"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const useCart = create(  persist(    (set) => ({      items: [],      add: (tea) => set((state) => ({ items: [...state.items, tea] })),      clear: () => set({ items: [] }),    }),    { name: "cart" },  ),); // Plain JavaScript, outside any component: it reads and changes the store directly.async function checkout() {  await sendOrder(useCart.getState().items);  useCart.getState().clear();} export default function App() {  const items = useCart((state) => state.items);  const add = useCart((state) => state.add);  return (    <>      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <p>In your cart: {items.map((tea) => tea.name).join(", ") || "nothing yet"}</p>      <button onClick={checkout} disabled={items.length === 0}>Place order</button>    </>  );}
ResultLoading…

Lines 11 to 20 make the store: line 12 wraps its function in persist, and line 18 names the saved entry "cart". checkout on lines 23 to 26 is plain JavaScript, not a component and not a hook: it reads the items with useCart.getState(), sends them to the pretend server in the api.js tab, then calls clear. Every component that reads the items renders with the empty cart.

Add two teas, then press Run again, which starts the result over like a refresh. The cart still lists them: line 12 read them back from localStorage. Press Place order: the console says Order sent: 2 teas, and half a second later the cart is empty.

Where does this state live?
  • Whether the size dropdown is open
  • The text in the search box as you type
  • The category a product list is filtered by
  • Page 2 of the reviews
  • The list of teas from the shop's API
  • Your past orders
  • The cart before checkout, shown in the header and the drawer
  • A list of messages any part of the app can show
Try it yourself
0 of 8 correct

Sort each piece of state into its home: one component's useState, the URL, a data library's cache, or a shared client store.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Every React app needs a state library." No. Many apps need none: their state fits in components, the URL, a data library, and a context or two.
  • "Put the server's data in the store." No. A copy of server data needs loading, refreshing, and sharing between requests. A data library does that; a store would only hold an old copy.
  • "Context is a state management library." Not quite. Context passes a value down the tree. The state lives in useState or useReducer, and every reader renders when the value changes.
  • "Context is slow, so avoid it." No. For values that rarely change, like a theme, it's the right tool. It costs only when many readers sit under a value that changes often.
  • "Redux is outdated." No. Redux Toolkit is maintained and widely used. It has more code than the others, in exchange for one strict pattern and its tools.
  • "A selector can return any object." No. A new object on every call means a change on every read, and React renders forever. Select single values, or use useShallow.
  • "With a store, components don't render." They do, when the part they read changes. A store only skips the ones whose part didn't.

Practice exercises

Every exercise shows its result, with the render counters. Press Edit & run to change the code; the result updates as you type.

Exercise 1 · Warm-upCount the renders with context

In the context cart, click Add Green tea twice. What does GiftNote's counter say? Predict, then click.

import { createContext, useContext, useState } from "react";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const CartContext = createContext(null); function CartProvider({ children }) {  const [items, setItems] = useState([]);  const [note, setNote] = useState("");  const add = (tea) => setItems([...items, tea]);  return (    <CartContext value={{ items, note, add, setNote }}>      {children}    </CartContext>  );} function CartBadge() {  const count = useContext(CartContext).items.length;  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const { add } = useContext(CartContext);  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const { note, setNote } = useContext(CartContext);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const { items } = useContext(CartContext);  const total = items.reduce((sum, tea) => sum + tea.price, 0);  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <CartProvider>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </CartProvider>  );}
ResultLoading…

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upCount the renders with a store

    In the store cart, type Hi in the note, then click Add Chai. What does CartTotal's counter say?

    import { createStore, useStore } from "./store.js";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const cartStore = createStore({ items: [], note: "" }); function add(tea) {  const state = cartStore.getState();  cartStore.setState({ ...state, items: [...state.items, tea] });} function setNote(note) {  cartStore.setState({ ...cartStore.getState(), note });} function CartBadge() {  const count = useStore(cartStore, (state) => state.items.length);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  // Reads nothing from the store, so the store never renders it.  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const note = useStore(cartStore, (state) => state.note);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useStore(cartStore, (state) =>    state.items.reduce((sum, tea) => sum + tea.price, 0),  );  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeA selector that never settles

      This store cart stops with an error before it shows anything. Press Edit & run, read the error, find the line, and fix it. What should the badge's selector return?

      import { createStore, useStore } from "./store.js";
      import RenderCount from "./RenderCount.jsx";
      
      const teas = [
        { id: "green", name: "Green tea", price: 3 },
        { id: "mint", name: "Mint tea", price: 4 },
        { id: "chai", name: "Chai", price: 5 },
      ];
      
      const cartStore = createStore({ items: [], note: "" });
      
      function add(tea) {
        const state = cartStore.getState();
        cartStore.setState({ ...state, items: [...state.items, tea] });
      }
      
      function setNote(note) {
        cartStore.setState({ ...cartStore.getState(), note });
      }
      
      function CartBadge() {
        const { count } = useStore(cartStore, (state) => ({ count: state.items.length }));
        return <p className="box">Cart: {count} <RenderCount /></p>;
      }
      
      function ProductList() {
        // Reads nothing from the store, so the store never renders it.
        return (
          <div className="box">
            {teas.map((tea) => (
              <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>
            ))}
            <RenderCount />
          </div>
        );
      }
      
      function GiftNote() {
        const note = useStore(cartStore, (state) => state.note);
        return (
          <p className="box">
            <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />
            <RenderCount />
          </p>
        );
      }
      
      function CartTotal() {
        const total = useStore(cartStore, (state) =>
          state.items.reduce((sum, tea) => sum + tea.price, 0),
        );
        return <p className="box">Total: ${total} <RenderCount /></p>;
      }
      
      export default function App() {
        return (
          <>
            <CartBadge />
            <ProductList />
            <GiftNote />
            <CartTotal />
          </>
        );
      }

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeEmpty the cart with Zustand

        Add an Empty cart button next to the badge. Click Add Green tea twice, then Empty cart. What does the badge say?

        import { create } from "zustand@5.0.16?dev&deps=react@19.3.1,react-dom@19.3.1";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const useCart = create((set) => ({  items: [],  note: "",  add: (tea) => set((state) => ({ items: [...state.items, tea] })),  setNote: (note) => set({ note }),})); function CartBadge() {  const count = useCart((state) => state.items.length);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const add = useCart((state) => state.add);  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => add(tea)}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const note = useCart((state) => state.note);  const setNote = useCart((state) => state.setNote);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useCart((state) =>    state.items.reduce((sum, tea) => sum + tea.price, 0),  );  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeRemove the last tea with Redux Toolkit

          Add a Remove last button that takes the last tea out of the cart. Click Add Green tea, Add Chai, then Remove last. What does the total say?

          import { configureStore, createSlice } from "@reduxjs/toolkit@2.13.0?dev&deps=react@19.3.1,react-dom@19.3.1";import { Provider, useDispatch, useSelector } from "react-redux@9.3.0?dev&deps=react@19.3.1,react-dom@19.3.1";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const cartSlice = createSlice({  name: "cart",  initialState: { items: [], note: "" },  reducers: {    added(state, action) {      state.items.push(action.payload);    },    noteChanged(state, action) {      state.note = action.payload;    },  },});const { added, noteChanged } = cartSlice.actions;const store = configureStore({ reducer: { cart: cartSlice.reducer } }); function CartBadge() {  const count = useSelector((state) => state.cart.items.length);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const dispatch = useDispatch();  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => dispatch(added(tea))}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const note = useSelector((state) => state.cart.note);  const dispatch = useDispatch();  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => dispatch(noteChanged(event.target.value))} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useSelector((state) =>    state.cart.items.reduce((sum, tea) => sum + tea.price, 0),  );  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <Provider store={store}>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </Provider>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeFree shipping, worked out by Jotai

            Shipping is free from $10. Add a derived atom that says whether the cart gets free shipping, and show Free shipping! next to the total. How many Chai do you add before it shows?

            import { atom, useAtom, useAtomValue, useSetAtom } from "jotai@3.0.1?dev&deps=react@19.3.1,react-dom@19.3.1";import RenderCount from "./RenderCount.jsx"; const teas = [  { id: "green", name: "Green tea", price: 3 },  { id: "mint", name: "Mint tea", price: 4 },  { id: "chai", name: "Chai", price: 5 },]; const itemsAtom = atom([]);const noteAtom = atom("");const countAtom = atom((get) => get(itemsAtom).length);const totalAtom = atom((get) =>  get(itemsAtom).reduce((sum, tea) => sum + tea.price, 0),); function CartBadge() {  const count = useAtomValue(countAtom);  return <p className="box">Cart: {count} <RenderCount /></p>;} function ProductList() {  const setItems = useSetAtom(itemsAtom);  return (    <div className="box">      {teas.map((tea) => (        <button key={tea.id} onClick={() => setItems((items) => [...items, tea])}>Add {tea.name}</button>      ))}      <RenderCount />    </div>  );} function GiftNote() {  const [note, setNote] = useAtom(noteAtom);  return (    <p className="box">      <input placeholder="Gift note" value={note} onChange={(event) => setNote(event.target.value)} />      <RenderCount />    </p>  );} function CartTotal() {  const total = useAtomValue(totalAtom);  return <p className="box">Total: ${total} <RenderCount /></p>;} export default function App() {  return (    <>      <CartBadge />      <ProductList />      <GiftNote />      <CartTotal />    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what kind of state it is, and which components read the part that changed.

              The State management quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which of these belongs in a data library's cache, not in a client store?

                Choose an answer to see the explanation.

              2. Question 2 of 7In the context cart, you type one letter in the gift note. Which components render?

                Read the code, then predictJSX
                <CartContext value={{ items, note, add, setNote }}>
                  <CartBadge />   {/* reads items */}
                  <ProductList /> {/* reads add */}
                  <GiftNote />    {/* reads note and setNote */}
                  <CartTotal />   {/* reads items */}
                </CartContext>
                // You type one letter in the gift note.

                Choose an answer to see the explanation.

              3. Question 3 of 7In the Zustand cart, you click Add Green tea. Which components render?

                Read the code, then predictJSX
                const count = useCart((state) => state.items.length);   // CartBadge
                const add = useCart((state) => state.add);               // ProductList
                const note = useCart((state) => state.note);             // GiftNote
                const total = useCart((state) => state.items.reduce(…)); // CartTotal
                // You click Add Green tea.

                Choose an answer to see the explanation.

              4. Question 4 of 7What does Zustand's create return?

                Choose an answer to see the explanation.

              5. Question 5 of 7Why can a Redux Toolkit reducer write state.items.push(action.payload)?

                Choose an answer to see the explanation.

              6. Question 6 of 7In Jotai, what is atom((get) => get(itemsAtom).length)?

                Choose an answer to see the explanation.

              7. Question 7 of 7An app's only shared state is the signed-in user and the theme. What should it use?

                Choose an answer to see the explanation.

              Key takeaways

              • Sort state first: one component's state, the URL, the server's data in a data library, and shared client state. Only the last can need a state library.
              • Lifted state and context are enough for many apps, especially for values that rarely change, like a theme or the signed-in user.
              • Every reader of a context renders when its value changes. A store with selectors renders only the readers whose part changed.
              • Zustand gives one store and a hook, with no provider. Redux Toolkit changes state only through actions and reducers, with Immer and strong tools. Jotai splits state into atoms and derived atoms.
              • Selectors must return values that stay the same while the state does: a new object on every read renders forever.
              • A store can be changed from plain JavaScript and saved with middleware like persist.

              Put each piece of state in its home.
              Reach for a state library only for shared client state that many components read and that changes often.

              Coming next: Structuring a React project. It shows where stores, hooks, and components go as an app grows.

              CompleteFrontend Clear concepts. Working examples.