React State Management in Depth #3: Lightweight Client State with Zustand

5 min read

In #2 we pulled server state out into TanStack Query. After that, the only state left to share globally is true client state: dark mode, whether the sidebar is open, the logged-in user’s info, the cart — values that are self-contained in the browser. This post looks at Zustand, the lightest way to handle that client state.

What’s lacking about useContext #

In Basics #12 we learned to share global values with useContext. For a single small value it’s plenty, but as an app grows, two things start to grate.

  • Provider nesting — every time the bundle of shared state grows, another Provider stacks up near the top of the tree.
  • Unnecessary re-renders — when a Context value changes, even components that use only part of it tend to re-render across the whole tree under the Provider.

Zustand tackles both head-on. There’s no Provider, and you subscribe to only the values you need, so a component re-renders only when those values change.

Installation and your first store #

Install
npm install zustand

You create a store with create. The defining trait is that the state and the functions that change it live together in one object.

store/useCounterStore.js
import { create } from "zustand";

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

set is the function that updates state. Unlike useReducer, it shallowly merges the returned object into the existing state. So returning just count keeps the other fields intact.

The thing worth noting here is that this store lives outside your components. There’s no need to wrap the tree in a Provider.

Using it in a component #

The store you created is simply called like a hook.

Counter.jsx
import { useCounterStore } from "./store/useCounterStore";

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

The part where you pass a function, useCounterStore((state) => state.count), is called a selector. It means “I’ll subscribe to only count from this store.” No matter which component in the tree calls it, it points to the same store, so the value is shared. You don’t have to pass it down through props.

Where the selector draws the re-render line #

Zustand’s performance advantage comes from this selector. A component re-renders only when the value its selector returns changes.

Suppose the store has several values.

A store with several values
const useUiStore = create((set) => ({
  isSidebarOpen: false,
  theme: "light",
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
  setTheme: (theme) => set({ theme }),
}));
A component that subscribes to theme only
function ThemeBadge() {
  // subscribes to theme only → does not re-render when isSidebarOpen changes
  const theme = useUiStore((state) => state.theme);
  return <span>Current theme: {theme}</span>;
}

ThemeBadge subscribes only to theme, so no matter how often isSidebarOpen toggles, it doesn’t redraw. With useContext, this is exactly the part that would have re-rendered together when the same Context value changed. This precision is why Zustand is light.

Note
If you use a selector that returns an object to pull several values at once ((s) => ({ a: s.a, b: s.b })), a new object is created every time, which can cause unnecessary re-renders. In that case, subscribe to each value separately, or apply a shallow comparison with Zustand’s useShallow.

Accessing it outside components too #

Since the store lives outside React, you can read and write state even from places that aren’t components.

Working with the store outside components
// read the current value (a one-off read, no subscription)
const current = useCounterStore.getState().count;

// change a value
useCounterStore.getState().increment();

// subscribe to changes
const unsubscribe = useCounterStore.subscribe((state) =>
  console.log("count changed:", state.count)
);

This is handy when you need global state in places like utility functions, event handlers, router guards, or plain modules.

persist — keeping state across refreshes #

Some values, like a dark mode setting or login status, should survive a refresh. Zustand’s persist middleware automatically saves the store to localStorage and restores it.

The persist middleware
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useThemeStore = create(
  persist(
    (set) => ({
      theme: "light",
      toggleTheme: () =>
        set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
    }),
    { name: "theme-storage" } // localStorage key name
  )
);

If you recall hand-writing localStorage save and restore in the Basics Todo app, this middleware does that whole job for you.

Where Zustand fits well #

  • When you need global client state but Redux feels heavy. The API is small and there’s almost no boilerplate.
  • When you don’t want to grow Provider nesting. The store lives outside React, so it doesn’t wrap the tree.
  • When you want precise control over re-renders. Subscriptions are scoped per selector.

Conversely, don’t put server data here. As emphasized in #1, it’s simpler to leave server state to #2’s TanStack Query and keep only client state in Zustand.

Wrapping up #

Zustand shares global client state with the least amount of code.

  • create((set) => ({ ... })) — state and updater functions in one object, no Provider needed
  • selector useStore((s) => s.x) — subscribe to only the values you need, re-render only when they change
  • getState / subscribe — access even outside components
  • persist middleware — automates localStorage save and restore

In the next post, “React State Management in Depth #4: Jotai and the Atom Model,” we approach the same client state from an entirely different angle. Where Zustand is top-down, gathering state into a single store, Jotai is bottom-up, breaking state into small atoms and assembling them.

X