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

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: 0

2️⃣ 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: 23

3️⃣ 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
//    0

4️⃣ 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)

📋 Quick Reference

TaskSyntax
Create a storeconst useStore = create((set) => ({ ... }))
Read a sliceconst n = useStore((s) => s.count)
Update (merge)set((s) => ({ count: s.count + 1 }))
Read outside ReactuseStore.getState().count
Write outside ReactuseStore.setState({ count: 0 })
Persistcreate(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

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

Related lessons