Lightweight State with Zustand
Reviewed & published by Brayan K
Zustand (German for "state") is a tiny global state library — about 1 KB — with almost no boilerplate. You call create() once to make a store, and any component can read it through the returned hook. No Provider, no actions, no dispatch. In this lesson you'll build a store, read it with selectors to avoid wasted re-renders, update it from anywhere, and learn when Zustand beats Context or Redux.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Create a store with create((set) => ({ ... }))
- Read state with the generated hook, no Provider needed
- Use selectors to subscribe to only the slice you need
- Update state with set() and read it with get()
- Update the store from outside React via getState/setState
- Decide between Zustand, Context, and Redux Toolkit
1️⃣ Creating a Store
You define a store once, in its own module. create() takes a function that receives set (and optionally get) and returns the initial state plus the functions that change it. It hands you back a hook — conventionally named useSomethingStore.
// store/counterStore.ts
import { create } from "zustand";
export const useCounterStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
decrement: () => set((s) => ({ count: s.count - 1 })),
reset: () => set({ count: 0 }), // pass an object OR an updater function
}));
// Component — no Provider, just import and call the hook:
function Counter() {
const count = useCounterStore((s) => s.count);
const increment = useCounterStore((s) => s.increment);
return <button onClick={increment}>Count: {count}</button>;
}Notice set does a shallow merge — you only return the keys that changed. The demo below models the store so you can watch set and get work:
// Zustand's create() returns a hook backed by a tiny store. We can't run
// real React here, so we model the STORE itself: state + the functions
// that update it, exactly the shape you pass to create((set) => ({...})).
function createStore(initializer) {
let state;
const listeners = new Set();
const get = () => state;
const set = (partial) => {
const next = typeof partial === "function" ? partial(state) : partial;
state = { ...state, ...next }; // merge, like Zustand's set
listeners.forEach((l) => l(state)); // notify subscribers (React re-renders)
};
state = initializer(set, get);
return { get, set, subscribe: (l) => listeners.add(l) };
}
// This is literally what you'd pass to create(...):
const counter = createStore((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));
counter.get().increment();
counter.get().increment();
counter.get().increment();
console.log("count:", counter.get().count); // count: 3
counter.get().reset();
console.log("after reset:", counter.get().count); // after reset: 02️⃣ Selectors: Subscribe to Only What You Need
If you call useStore() with no argument, your component re-renders on every store change. Pass a selector and you subscribe to a single slice — the component re-renders only when that slice changes. This is the most important habit in Zustand.
// ❌ Re-renders whenever ANY field changes:
const store = useCounterStore();
// ✅ Re-renders only when count changes:
const count = useCounterStore((s) => s.count);
// Selecting multiple fields? Use useShallow so a new object
// reference doesn't trigger a re-render every time:
import { useShallow } from "zustand/react/shallow";
const { count, increment } = useCounterStore(
useShallow((s) => ({ count: s.count, increment: s.increment }))
);Selectors are just pure functions of state — they can also derive values (a total, a count) without storing them. The demo shows selecting and deriving from a cart store:
// The performance trick in Zustand is SELECTORS. A component reads only
// the slice it needs, so unrelated updates don't re-render it. We model
// "selecting" as just reading a derived view of the state.
function createStore(initializer) {
let state;
const set = (p) => { state = { ...state, ...(typeof p === "function" ? p(state) : p) }; };
const get = () => state;
state = initializer(set, get);
return { get, set };
}
const cart = createStore((set) => ({
items: [{ name: "Book", price: 12 }, { name: "Pen", price: 3 }],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}));
// Selectors: pure functions of state. In React: useCart(s => s.items.length)
const selectCount = (s) => s.items.length;
const selectTotal = (s) => s.items.reduce((sum, i) => sum + i.price, 0);
console.log("count:", selectCount(cart.get())); // count: 2
console.log("total:", selectTotal(cart.get())); // total: 15
cart.get().addItem({ name: "Mug", price: 8 });
console.log("count:", selectCount(cart.get())); // count: 3
console.log("total:", selectTotal(cart.get())); // total: 233️⃣ Your Turn: A Tasks Store
Time to wire up the actions yourself. Fill in the blanks so addTask appends without mutating, and clear empties the list. Remember: set with an updater receives current state and returns the changed keys.
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Model a Zustand store that tracks a list of tasks.
function createStore(initializer) {
let state;
const set = (partial) => {
const next = typeof partial === "function" ? partial(state) : partial;
state = { ...state, ...next };
};
const get = () => state;
state = initializer(set, get);
return { get, set };
}
const tasks = createStore((set) => ({
items: [],
// 1) addTask should append a new task to items (return new array, don't mutate)
addTask: (text) => set((s) => ({ items: [...s.items, ___] })), // 👉 text
// 2) clear should empty the list
clear: () => set({ items: ___ }), // 👉 []
}));
tasks.get().addTask("Write tests");
tasks.get().addTask("Ship feature");
console.log(tasks.get().items.length); // expect 2
tasks.get().clear();
console.log(tasks.get().items.length); // expect 0
// ✅ Expected output:
// 2
// 04️⃣ Updating from Outside React
Because the store lives in a module, you can touch it without a component. The hook itself carries getState() and setState(). This is gold for websocket handlers, keyboard listeners, or any imperative code.
// Anywhere — not inside a component, not inside a hook:
const current = useCounterStore.getState().count; // read
useCounterStore.getState().increment(); // call an action
useCounterStore.setState({ count: 100 }); // write directly
// Subscribe to changes outside React (e.g. log to analytics):
const unsubscribe = useCounterStore.subscribe(
(state) => console.log("count is now", state.count)
);
// later: unsubscribe();5️⃣ Persistence with Middleware
Zustand ships small middlewares you wrap around your initializer. persist saves the store to localStorage automatically, so state survives a page reload — perfect for a theme, a cart, or recent searches.
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useSettingsStore = create(
persist(
(set) => ({
theme: "light",
toggleTheme: () =>
set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}),
{ name: "app-settings" } // localStorage key
)
);
// Reload the page and the theme is remembered — no extra code.Reorder Challenge
These lines of a Zustand useAuthStore are scrambled. Put them in the correct order:
A. user: null,
B. export const useAuthStore = create((set) => ({
C. login: (user) => set({ user }),
D. logout: () => set({ user: null }),
E. }));Quick Recall
1) Do you need a <Provider> to use a Zustand store?
No. The store lives in a module and the generated hook reads it directly. That's a key difference from Context and Redux.
2) Why pass a selector like useStore(s => s.count) instead of useStore()?
So the component re-renders only when that specific slice changes, not on every store update. Narrow selection is the main performance habit.
3) How do you update the store from a plain websocket handler outside React?
Use useStore.getState() to call an action or useStore.setState(...) to write directly — both work anywhere.
Common Errors (and the fix)
- Selecting a new object every render: useStore(s => ({ a: s.a, b: s.b })) returns a fresh object each time and re-renders constantly. Wrap it in useShallow.
- Mutating state directly: s.items.push(x) won't notify subscribers. Return a new array/object from set, or use the immer middleware.
- Calling the hook outside a component: useStore() is a hook. Outside React, use useStore.getState() instead.
- Forgetting set merges shallowly: nested objects are replaced, not deep-merged. Spread nested state yourself: set(s => ({ user: { ...s.user, name } })).
📋 Quick Reference
| Task | Syntax |
|---|---|
| Create a store | const useStore = create((set) => ({ ... })) |
| Read a slice | const n = useStore((s) => s.count) |
| Update (merge) | set((s) => ({ count: s.count + 1 })) |
| Read outside React | useStore.getState().count |
| Write outside React | useStore.setState({ count: 0 }) |
| Persist | create(persist(init, { name: "key" })) |
Mini-Challenge: A Likes Store
Model a store with a likes count and a like() action. Call like() three times and log the result — the engine behind every "heart" button.
// Build a store with likes (starts at 0) and a like() action.
function createStore(initializer) {
let state;
const set = (p) => { state = { ...state, ...(typeof p === "function" ? p(state) : p) }; };
const get = () => state;
state = initializer(set, get);
return { get, set };
}
const post = createStore((set) => ({
likes: 0,
like: () => set((s) => ({ likes: s.likes + 1 })),
}));
// your code here: call like() three times, then console.log(post.get().likes)🎉 Lesson Complete
- ✅ create((set) => ({ ... })) returns a hook — no Provider needed
- ✅ Read with a selector to subscribe to just one slice
- ✅ set merges shallowly; return only the changed keys
- ✅ getState/setState let you touch the store outside React
- ✅ Middleware like persist adds localStorage in one wrap
Practice quiz
Do you need a Provider component to use a Zustand store?
- Yes, you must wrap your app in a Provider
- Only for nested components
- No — create() returns a hook you import anywhere; the store lives in a module
- Only in production builds
Answer: No — create() returns a hook you import anywhere; the store lives in a module. Zustand needs no Provider. create() returns a hook backed by a module-level store, so any component can subscribe directly.
What does create() return?
- A hook (conventionally useSomethingStore) backed by the store
- A reducer function
- A React context object
- A plain state object
Answer: A hook (conventionally useSomethingStore) backed by the store. create((set) => ({ ... })) returns a hook you call to read the store, conventionally named useSomethingStore.
Why pass a selector like useStore(s => s.count) instead of useStore()?
- It is required syntax
- It makes the store global
- To avoid needing a Provider
- So the component re-renders only when that slice changes, not on every store update
Answer: So the component re-renders only when that slice changes, not on every store update. A selector subscribes the component to a single slice, so it re-renders only when that slice changes. Narrow selection is the main Zustand performance habit.
How does Zustand's set() update state by default?
- It replaces the entire state object
- It shallow-merges the returned keys into the existing state
- It deep-merges nested objects
- It mutates state in place
Answer: It shallow-merges the returned keys into the existing state. set does a shallow merge — you return only the keys that changed and Zustand merges them into the existing state.
How do you update the store from outside React (e.g. a websocket handler)?
- Use useStore.getState() to call an action or useStore.setState() to write
- Call the hook useStore() directly
- You cannot update from outside React
- Wrap the code in a Provider
Answer: Use useStore.getState() to call an action or useStore.setState() to write. The hook exposes getState() and setState() that work anywhere — in listeners, websocket handlers, or plain utilities — outside any component.
What is wrong with useStore(s => ({ a: s.a, b: s.b })) without useShallow?
- Nothing, it is optimal
- It only selects one field
- It returns a new object reference every render, causing constant re-renders
- It mutates the store
Answer: It returns a new object reference every render, causing constant re-renders. Returning a fresh object each render means the selector result is never reference-equal, so the component re-renders constantly. Wrap it in useShallow.
What is a selector in Zustand?
- A middleware
- A pure function of state that reads (and can derive) a slice
- A React component
- An action creator
Answer: A pure function of state that reads (and can derive) a slice. Selectors are pure functions of state, like s => s.items.length, that read a slice or derive a value without storing it.
Why does s.items.push(x) fail to update subscribers in Zustand?
- push is not a function
- Arrays cannot be stored
- It needs a Provider
- It mutates state in place instead of returning a new array via set
Answer: It mutates state in place instead of returning a new array via set. Mutating state directly does not notify subscribers. Return a new array/object from set, e.g. set(s => ({ items: [...s.items, x] })), or use the immer middleware.
What does the persist middleware do?
- Logs every action to the console
- Saves the store to localStorage so state survives a page reload
- Adds undo/redo
- Makes selectors faster
Answer: Saves the store to localStorage so state survives a page reload. persist wraps your initializer and automatically saves/restores the store to localStorage, so state like a theme or cart survives reloads.
How is Zustand different from Redux Toolkit?
- Zustand requires more boilerplate
- Zustand cannot be used in large apps
- Zustand has far less ceremony — no actions, reducers, dispatch, or providers
- Zustand only works with class components
Answer: Zustand has far less ceremony — no actions, reducers, dispatch, or providers. Both are global stores, but Zustand is minimalist: you write state plus the functions that mutate it and read with a hook — no actions, reducers, dispatch, or Provider.
Continue this course
- Previous: Global State with Redux Toolkit
- Next: Checkpoint: A Mini App — Combine state management + data fetching into a real build — then a quiz
- Quick reference: React cheat sheet