DEV Community

The Practical Developer
The Practical Developer

Posted on Originally published at practicaldevblog.blogspot.com

Zustand: React State Without the Boilerplate

Picture a small shop page with a cart icon in the corner. A visitor adds a tote bag and a mug. Three parts of the page need to know what happened: the cart icon showing the count, the cart drawer listing the items, and the checkout button showing the total. If each part keeps its own copy of the cart, the numbers drift apart. The usual fix is to lift the state up to a shared parent, but that turns every middle component into a courier for data it never uses. That is the problem Zustand sets out to solve, and the question is whether a tiny store does the job better than the tools React already ships with.

What I would check before using it

My first check is how much state needs sharing. One cart icon and one list on the same page can live in a parent component, and a library buys nothing there. Once three or more distant parts read the same state, or it must survive a route change, a shared store earns its keep. Count the readers first.

My second check is the learning curve, and Zustand is light here. A store is a function call with state fields plus the functions that change them. No provider, no reducer, no action types. Anyone who knows useState can read a Zustand store, and that matters, because the cost of a library is paid whenever someone new opens the file.

My third check is how components subscribe. With React context, every reader re-renders on any change, even parts it never touches. Zustand lets each component pick just the slice it needs, so the cart icon re-renders on a count change and ignores a price edit. On a small shop that is invisible; on a busy page it is the difference between a snappy interface and one that stutters.

My fourth check is the escape hatches. Zustand also works outside React, through its vanilla store, so the same cart logic runs in a plain script or a test. That beats context, which only exists inside a React tree.

My verdict, as judgement: use Zustand when several components share state that changes a lot and you want the simplest tool. Skip it when the state lives in one component, or when you need time travel debugging or a strict action log. The recommendation flips when the shared state shrinks to one parent and child, or the team needs a framework's guardrails rather than a plain object's freedom.

One small shop cart, run end to end

The example is a small shop cart built with the vanilla version of Zustand, so it runs in plain Node with no React at all. The store holds an array of cart items and three actions: add an item, remove one by id, and clear the cart. A total function computes the price from the items, so the total can never drift out of sync with the list. A subscriber logs the item count and total after every change, standing in for the parts of a page that would re-render in React.

Here is the store and the script that drives it. The products are a canvas tote bag at $18, a ceramic mug at $14.50, and a notebook set at $9.25:

const { createStore } = require("zustand/vanilla");

const products = [
  { id: 1, name: "Canvas tote bag", price: 18.0 },
  { id: 2, name: "Ceramic mug", price: 14.5 },
  { id: 3, name: "Notebook set", price: 9.25 },
];

const cartStore = createStore((set, get) => ({
  items: [],
  addItem: (product, qty = 1) =>
    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, qty: i.qty + qty } : i
          ),
        };
      }
      return { items: [...state.items, { ...product, qty }] };
    }),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
  clearCart: () => set({ items: [] }),
  total: () =>
    get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));

cartStore.subscribe((state) => {
  const count = state.items.reduce((n, i) => n + i.qty, 0);
  console.log(`Cart changed: ${count} item(s), total $${state.total().toFixed(2)}`);
});

console.log("Adding 2 tote bags and 1 mug...");
cartStore.getState().addItem(products[0], 2);
cartStore.getState().addItem(products[1], 1);

console.log("Adding 3 notebook sets...");
cartStore.getState().addItem(products[2], 3);

console.log("Removing the mug...");
cartStore.getState().removeItem(2);

console.log("Final cart:");
for (const item of cartStore.getState().items) {
  console.log(`- ${item.qty} x ${item.name} @ $${item.price} = $${(item.price * item.qty).toFixed(2)}`);
}
console.log("Grand total: $" + cartStore.getState().total().toFixed(2));
Enter fullscreen mode Exit fullscreen mode

This is the real output from running it with Node:

Adding 2 tote bags and 1 mug...
Cart changed: 2 item(s), total $36.00
Cart changed: 3 item(s), total $50.50
Adding 3 notebook sets...
Cart changed: 6 item(s), total $78.25
Removing the mug...
Cart changed: 5 item(s), total $63.75
Final cart:
- 2 x Canvas tote bag @ $18 = $36.00
- 3 x Notebook set @ $9.25 = $27.75
Grand total: $63.75
Enter fullscreen mode Exit fullscreen mode

Three things are worth noticing. First, the total is always computed from the items, never stored separately, so adding a notebook set moves the total from $50.50 to $78.25 with no extra code. Second, the subscriber fired exactly once per change, which is how a React component would know to re-render. Third, adding the same product twice creates no duplicate line: the second tote bag merged into the existing one by raising the quantity, which is the behaviour a real shop wants.

The thing that went wrong was a direct push into the state array. Instead of calling the addItem action, I reached into the store and pushed a pen set straight onto the items array:

cartStore.getState().items.push({ id: 4, name: "Pen set", price: 6.5, qty: 1 });
Enter fullscreen mode Exit fullscreen mode

Reading the store afterwards showed 2 items, so the push had worked, but the subscriber never fired. Here is the real output from that run:

Items before the bad line: 1
Items after direct push: 2
Subscriber calls after direct push: 0
Adding a notebook set the proper way...
Subscriber fired, call #1
Items now: Canvas tote bag, Pen set, Notebook set
Subscriber calls total: 1
Enter fullscreen mode Exit fullscreen mode

The push changed the data without telling anyone. In React the cart icon would still show the old count, because no re-render was triggered, and the pen set would only appear after the next real update forced a render. That is a silent bug: nothing throws, nothing looks broken, and the page quietly shows stale numbers. The fix is the rule Zustand is built around: never touch the state object directly, always go through set. The addItem action creates a new array with the spread operator, and that new array is what wakes the subscribers. Keep that rule and the store stays honest; break it and the store lies to the page.

Common mistakes and how to fix them

First, mutating state directly, as in the broken run above. The symptom is a store holding new data while the page shows old numbers, with no error anywhere to point at. The fix is to always change state through set with a new array or object, using the spread operator or map and filter, and never push, splice, or assign onto state from getState.

Second, subscribing to the whole store. A component that reads the entire store re-renders on every change, even one it never displays, so a price edit re-renders the cart icon. The fix is to select only the slice it needs, for example just the item count or just the total, and let the rest of the store change without disturbing it.

Third, holding a stale snapshot. Calling getState once, saving the result, and reading it later gives you the state as it was at that moment, not as it is now, so a total computed from it stays frozen while the cart keeps changing. The fix is to call getState fresh inside the action that needs it, or to subscribe, so the value you read is always current.

My call on Zustand

Zustand does one job and stays out of the way: it holds shared state in an object and tells the components that care when it changes. The cart above needed a short store, a computed total, and a subscriber, and nothing in it was new. That is the whole appeal, and also the limit: dozens of tangled stores can turn that freedom into a hunt for which action changed what. For a shop cart, a theme toggle, or a dashboard, it is the right size. Try it on one shared piece of state and see whether the code gets shorter. If it does, keep going; if the state was never shared, a store was never needed.

Further reading

Originally published at https://practicaldevblog.blogspot.com/2026/11/zustand-react-state-without-boilerplate.html

Top comments (0)