Lesson 15 · Ecosystem
Application state management
Kinds of state and how to choose: Context vs. Zustand vs. Redux Toolkit.
Loading lesson…
Lesson 15 · Ecosystem
Kinds of state and how to choose: Context vs. Zustand vs. Redux Toolkit.
Loading lesson…
When you move house, you don't throw everything into one giant wardrobe in the hall. Milk goes in the fridge, keys in your pocket, documents in a folder, and shared things – spices, say – in the pantry, where the whole family reaches. Each thing has its place according to how it's used.
The question "Redux or Zustand?" is like the question "which giant wardrobe should I buy for the hall?". Most state in an app doesn't belong in any global wardrobe. Over the course we've met five places, and each suits a different kind of state:
| Kind of state | Like at home | Example | Where it goes |
|---|---|---|---|
| Server | Food in the fridge: bought, with a use-by date | Products, users, orders | TanStack Query / RTK Query / loaders |
| In the address | The address on an envelope | Filters, page, selected tab, search | Router (search params) |
| Form | Papers on the desk | Field values, errors, submitting | useState / Actions / React Hook Form |
| Local UI | Things in your pocket | An open modal, an expanded section | useState / useReducer in the component |
| Shared client | The shared pantry | The signed-in user, the theme, the cart, an editor in progress | Context, Zustand, Redux… |
Take an e-shop page: a product list with a category filter and a search, an "Add to cart" button, a cart icon in the header, an expandable product detail and a dark mode switch. Let's go through each piece of state and ask where it belongs:
| State | Question | Where |
|---|---|---|
| The product list | Does it belong to the server, and can it change there? | TanStack Query |
| The selected category, the search text | Should it survive sending a link? | Search params |
| An expanded product detail | Does anyone besides this card care? | useState in the card |
| The cart contents | Do the header and the page both need it, does it change often? | Zustand / Redux |
| Dark mode | Does everything need it, does it change rarely? | Context (or Zustand too) |
| The number of items in the cart | Can it be calculated from the cart contents? | Nowhere – calculate it (a selector) |
Out of six things, only one (the cart) really needs a shared store. That's typical.
Remember the lesson on Context: Context is a PA system. When the announcement changes, everyone has to listen. If the cart were in Context, every product added would re-render all the components that read the cart – even the "Add to cart" buttons that only want the add function.
The store of libraries like Zustand and Redux works more like a tailored news subscription. Each component says exactly what interests it – "only the item count", "only the total price", "only the add function" – and gets a message only when that changes. The function that picks only what's needed from the whole state is called a selector:
const count = useCartStore((state) => state.items.length) // "send me just the count"
After every change the store calls the selector and compares the result with the previous one. If it's the same, the component doesn't re-render.
import { create } from 'zustand'interface CartStore {items: { product: Product; qty: number }[]add: (product: Product) => voidclear: () => void}const useCartStore = create<CartStore>()((set) => ({items: [], // stateadd: (product) => // an actionset((state) => ({ items: [...state.items, { product, qty: 1 }] })),clear: () => set({ items: [] }),}))
create creates the store and the useCartStore hook at once. No Provider – the store lives outside React and anyone can "subscribe" to it.set works like the setter from useState: it gets the previous state and returns the changes (Zustand merges them with the rest).Components anywhere in the tree pick what they need:
function AddButton({ product }: { product: Product }) {const add = useCartStore((s) => s.add) // the function never changesreturn <button onClick={() => add(product)}>Add to cart</button>}function CartBadge() {const count = useCartStore((s) => s.items.reduce((n, i) => n + i.qty, 0))return <span>🛒 {count}</span>}
What happens after clicking "Add to cart":
add(product) calls set and the store has a new state.CartBadge: the count was 0, now it's 1 → it re-renders.AddButton: the add function is still the same → it doesn't re-render.What you see: Three components sharing one cart store: buttons for adding, a badge with the item count and the total price. Each card flashes yellow when it re-renders.
Try it:
add function.The takeaway: Each component gets news only about what it picked with its selector. No Provider and no needless re-rendering.
Loading the interactive part…
Zustand can add useful abilities through so-called middleware:
import { persist, devtools } from 'zustand/middleware'const useSettings = create<Settings>()(devtools( // a connection to Redux DevToolspersist( // saving to localStorage(set) => ({ theme: 'light', setTheme: (theme) => set({ theme }) }),{ name: 'settings' },),),)// Reading and writing outside React too (in utilities, in tests)useSettings.getState().setTheme('dark')
In the lesson on reducers we explained the bank account: nobody overwrites the balance, everyone just reports transactions and one place calculates the new state by the rules. Redux is exactly that, only for the whole app: one store, actions, reducers – plus a statement of all transactions in Redux DevTools.
Redux Toolkit (RTK) is the modern, official way to write Redux. It removes most of the repetitive code:
const todosSlice = createSlice({name: 'todos',initialState: { items: [] as Todo[] },reducers: {added(state, action: PayloadAction<string>) {state.items.push({ id: Date.now(), text: action.payload, done: false })},toggled(state, action: PayloadAction<number>) {const todo = state.items.find((t) => t.id === action.payload)if (todo) todo.done = !todo.done},},})export const { added, toggled } = todosSlice.actions // the actions are generated automaticallyconst store = configureStore({ reducer: { todos: todosSlice.reducer } })
createSlice = a piece of state + its reducers. From the reducer names it creates the actions too (added('Go shopping') → { type: 'todos/added', payload: 'Go shopping' }).configureStore puts the slices together into one store and turns on DevTools.In components the store is read with selectors and changed with actions:
function TodoApp() {const todos = useAppSelector((s) => s.todos.items) // subscribing to just this partconst dispatch = useAppDispatch()return <button onClick={() => dispatch(added('New task'))}>Add</button>}// Derived data: createSelector memoises it and recalculates only when its inputs changeconst selectActive = createSelector([(s: RootState) => s.todos.items], (items) => items.filter((t) => !t.done))
What you see: A task list in a Redux Toolkit store: a slice with the actions added, toggled and filterChanged, typed hooks and a memoised selector for the visible tasks.
Try it:
selectVisibleTodos selector calculated the visible tasks from items and filter.The takeaway: Redux Toolkit is the reducer from lesson 5 for the whole app: actions describe what happened, the slice decides how the state changes, and selectors pick what each component needs.
Loading the interactive part…
| Context + useReducer | Zustand | Redux Toolkit | |
|---|---|---|---|
| Analogy | A PA system | A tailored news subscription | A bank account with a statement |
| Installation / repetitive code | None / medium | Small / minimal | Medium / more structure |
| Selectors (fine-grained subscriptions) | ❌ re-renders all listeners | ✅ | ✅ (+ memoised selectors) |
| DevTools, time travel | ❌ | ✅ through middleware | ✅ built in |
| Server data | ❌ | ❌ (combine with TanStack Query) | ✅ RTK Query |
| When | Rarely changing data: theme, user, language | Medium apps, a quick start, few rules | Big apps and teams, complex logic, an audit of actions |
Two components, one shared store. Fill in the action and the reading of the state.
CHALLENGE: A counter in Zustand
The store has `count` and an increment action that does nothing yet.
Display and Buttons are two different components – they share state through the store, without props.
1. In the store, fill in increment: set((s) => ({ count: s.count + 1 }))
2. In Display, read count with a selector: const count = useCounter((s) => s.count)Loading the interactive part…
The first challenge practises section 3: move the favourite products into Zustand and add saving. The second extends section 4 with asynchronous loading in Redux.
From carrying a message across the whole school to a tailored news subscription – with saving to localStorage.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
The complete asynchronous flow in Redux: pending → fulfilled/rejected (the washing machine from the lesson on reducers), typed hooks and a memoised selector.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
useShallow, createSelector).create)create – from the library zustand: a global state library (lesson 15).row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).