State Management

Course 3 · Ch 1
State Management Beyond Context
Welcome to Advanced. Zustand and Redux Toolkit — what they solve that Context + useReducer eventually can't

Context plus useReducer — used in the Shopping Cart project and formalized in Intermediate Chapters 2–3 — genuinely works for global state, and Chapter 7's useMemo fix solved its biggest re-render problem. It still has real limits at scale: every piece of global state generally needs its own Provider wrapping the app, and even with memoization, splitting state cleanly across many unrelated concerns gets unwieldy. Zustand and Redux Toolkit are the two most common dedicated answers to that — different philosophies, both still built around the same core idea: state living outside any one component, with components subscribing to the pieces they need.

Zustand — Minimal Global State

// npm install zustand import { create } from "zustand"; const useCounterStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }), })); // usage — anywhere, no Provider needed at all function Counter() { const count = useCounterStore((state) => state.count); const increment = useCounterStore((state) => state.increment); return <button onClick={increment}>{count}</button>; }

create() builds a store and a hook for reading it, in one step — the store itself lives entirely outside the React component tree, so there's no <Provider> wrapping anything, unlike every Context example so far. Each call to useCounterStore(selector) subscribes only to the specific piece of state that selector returns — Counter only re-renders when count itself changes, not when some unrelated piece of the same store changes, solving Context's all-or-nothing re-render problem directly rather than needing the useMemo workaround from Chapter 7.

Rebuilding the Shopping Cart with Zustand

const useCartStore = create((set) => ({ items: [], addToCart: (product) => set((state) => { const existing = state.items.find((i) => i.id === product.id); if (existing) { return { items: state.items.map((i) => i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i ), }; } return { items: [...state.items, { ...product, quantity: 1 }] }; }), removeFromCart: (id) => set((state) => ({ items: state.items.filter((i) => i.id !== id) })), }));

The duplicate-entry-check logic from Project 6's addToCart is identical here — Zustand changes where state lives and how components subscribe to it, not the immutable-update rules underneath. Any component anywhere can call useCartStore((s) => s.items) or useCartStore((s) => s.addToCart) directly — no CartProvider wrapping the app, no useContext call.

Redux Toolkit — More Structure, More Ceremony

// npm install @reduxjs/toolkit react-redux import { createSlice, configureStore } from "@reduxjs/toolkit"; import { Provider, useSelector, useDispatch } from "react-redux"; const counterSlice = createSlice({ name: "counter", initialState: { count: 0 }, reducers: { increment: (state) => { state.count += 1; }, // "mutation" here is safe — Redux Toolkit uses Immer underneath }, }); const store = configureStore({ reducer: { counter: counterSlice.reducer } }); // still needs a Provider, wrapping the app once — same shape as Context <Provider store={store}><App /></Provider> // usage const count = useSelector((state) => state.counter.count); const dispatch = useDispatch(); dispatch(counterSlice.actions.increment());

Redux Toolkit follows the same dispatch/action/reducer shape as useReducer from Intermediate Chapter 3, scaled up with "slices" for organizing many pieces of state, a single combined store, and (unlike a hand-written reducer) the ability to write code that looks like direct mutation inside a reducer safely — Redux Toolkit uses a library called Immer underneath to translate that into a proper immutable update automatically. It does still need a <Provider> wrapping the app, much like Context.

Neither of these is the default choice
Both tools solve real problems at a certain scale — many independent pieces of global state, performance issues from Context re-renders that useMemo can't fully solve, or a team that specifically wants Redux's strict structure and devtools ecosystem. If useReducer + Context (with the Chapter 7 useMemo fix) is working fine for an app's actual needs, that's a completely reasonable place to stop — neither Zustand nor Redux Toolkit need to be reached for "just in case."
Context + useReducerZustandRedux Toolkit
Needs a Provider?YesNoYes
Selective re-renders?Manual (useMemo)Built inBuilt in (useSelector)
Setup ceremonyLowVery lowHigher
Ecosystem / devtoolsNone built-inSmall, growingLarge, mature
Best fitSmall-to-medium global stateMost apps wanting simplicityLarge teams, strict conventions

Coding Challenges

Challenge 1

Build a Zustand counter store with increment, decrement, and reset actions, and use it from two completely unrelated components, confirming both stay in sync with no Provider anywhere in the app.

📄 View solution
Challenge 2

Rebuild Project 6's shopping cart logic (items, addToCart with the duplicate-check behavior, removeFromCart) as a Zustand store, and use it from a product list component and a separate cart-display component.

📄 View solution
Challenge 3

Build the same counter from Challenge 1 using Redux Toolkit instead (createSlice, configureStore, a Provider, useSelector, useDispatch), and compare the amount of code each approach needed for identical behavior.

📄 View solution

Chapter 1 Quick Reference

  • Zustandcreate() builds a store + hook; no Provider; selective subscriptions built in
  • Redux ToolkitcreateSlice/configureStore; needs a Provider; mature ecosystem/devtools
  • Both still rely on the same immutable-update rules from Fundamentals Ch 3 underneath (Redux Toolkit just hides it via Immer)
  • Neither replaces Context + useReducer by default — reach for them when scale genuinely demands it
  • Next chapter: React Query — the same pattern applied specifically to server data, first used informally in Project 7