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.
- 01Sort an app's stateTell one component's state, shared state, URL state, and server state apart, and give each the tool it needs.
- 02See 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.
- 03Compare 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.
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> );}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.
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:
useStatein 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
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.
| Kind of state | In the shop | Where it lives |
|---|---|---|
| One component's state | Whether a menu is open. The text in a box as you type. | useState or useReducer, in that component. |
| State a few nearby components share | A 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 read | The 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 data | Products, stock, past orders. | A data library's cache, such as TanStack Query's. |
| Shared client state that changes often | A 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.
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):
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 /> </> );}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:
4 readers4 selectorsPress Type a letter in the note, then Add Green tea, and compare the counters on both sides.
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:
npm install zustand
npm install @reduxjs/toolkit react-redux
npm install jotaiimport { 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 /> </> );}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:
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.
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> );}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:
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:
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 /> </> );}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:
How to read the table: each row asks one question about the same cart, in each library.
| Question | Zustand | Redux Toolkit | Jotai |
|---|---|---|---|
| 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 cart | 6. | 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:
- 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.
- 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.
- 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.
- 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.
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> </> );}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.
- 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
Sort each piece of state into its home: one component's useState, the URL, a data library's cache, or a shared client store.
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
useStateoruseReducer, 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.
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> );}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> );}GiftNote rendered once at the start. Each Add Green tea makes a new context value, and every reader renders, so GiftNote renders twice more: renders: 3, though the note didn't change.
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 /> </> );}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 /> </> );}CartTotal's selector adds up the prices. Typing Hi changes the note, not the total, so it gives the same answer twice and CartTotal doesn't render. Add Chai changes the total from 0 to 5: one render. With the first render, renders: 2.
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 />
</>
);
}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 /> </> );}({ count: … }) is a new object on every call, so useSyncExternalStore sees a new answer every time it checks, and renders again forever until React stops it. A number like state.items.length is the same while the items are, so the badge renders only when the count changes.
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 /> </> );}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 }), clear: () => set({ items: [] }),})); function CartBadge() { const count = useCart((state) => state.items.length); const clear = useCart((state) => state.clear); return ( <p className="box"> Cart: {count} <button onClick={clear}>Empty cart</button> <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 /> </> );}set({ items: [] }) merges an empty list into the state and keeps the note. The badge and the total read the items, so they render with Cart: 0 and Total: $0. ProductList and GiftNote don't render.
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> );}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; }, lastRemoved(state) { state.items.pop(); }, },});const { added, noteChanged, lastRemoved } = 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> ))} <button onClick={() => dispatch(lastRemoved())}>Remove last</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> );}The new reducer pops the last item off the draft, and Immer makes a new state without it. After Green tea and Chai, Remove last leaves Green tea, so the total is Total: $3. The action has no payload, so lastRemoved() takes no argument.
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 /> </> );}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),);const freeShippingAtom = atom((get) => get(totalAtom) >= 10); 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); const freeShipping = useAtomValue(freeShippingAtom); return ( <p className="box"> Total: ${total} {freeShipping && "Free shipping!"} <RenderCount /> </p> );} export default function App() { return ( <> <CartBadge /> <ProductList /> <GiftNote /> <CartTotal /> </> );}A derived atom can read another derived atom. One Chai makes the total $5; two make $10, and get(totalAtom) >= 10 turns true, so Free shipping! shows. You never set it: Jotai works it out when the items change.
Check your understanding
For each question, ask what kind of state it is, and which components read the part that changed.
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.
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.
Question 3 of 7In the Zustand cart, you click Add Green tea. Which components render?
Read the code, then predictJSXconst 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.
Question 4 of 7What does Zustand's
createreturn?Choose an answer to see the explanation.
Question 5 of 7Why can a Redux Toolkit reducer write
state.items.push(action.payload)?Choose an answer to see the explanation.
Question 6 of 7In Jotai, what is
atom((get) => get(itemsAtom).length)?Choose an answer to see the explanation.
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.