Use Cases (Updated: 6/7/2026)

Zustand Without the Re-render Tax: Selectors and persist Done Right

Keep Zustand fast: selectors that limit re-renders, safe persist partialize, and optimistic updates in runnable TypeScript with Claude Code.

Zustand Without the Re-render Tax: Selectors and persist Done Right

I asked Claude Code to “set up state management with Zustand,” and five minutes later I had a working store.

It felt great, so I wired it into an admin dashboard and dumped everything in there: filters, the cart, the auth state, all of it. The early days were smooth. Things broke the day I added a notification badge. Every time a single toast appeared, the entire unrelated data table re-rendered, and scrolling started to stutter.

The culprit was not Zustand. It was me. I never decided what to put in the store or how to read it back out, and I shipped the generated code as-is. Zustand is light, which means a sloppy design shows up directly as slowness.

This article is the playbook I locked down so I would never trip over the same wire twice, with copy-paste TypeScript you can run today.

Key takeaways

  • Put only two kinds of values in Zustand: data shared across distant screens, and UI state that a URL cannot express. A list fetched from the server needs cache management, so treat it separately.
  • Almost every “the screen got heavy” bug traces back to missing selectors. Do not subscribe to the whole store. Pick the exact value you need, and when you return several at once, wrap them in useShallow for a shallow comparison.
  • persist is handy, but if you do not narrow the saved fields with partialize, personal data ends up sitting in localStorage. The only safe thing to save is anonymous UI preferences.
  • For async optimistic updates, you need a request ID plus the “value to roll back to” as a pair. Without both, a stale response can overwrite newer state.
  • Torn between Redux, Context, and Zustand? Decide based on how much you value low boilerplate versus fine-grained re-render control. Zustand is the pick when you want both to stay lightweight.

What Zustand actually does

Zustand is a state-management library for React. State management means collecting the values you want to share across screens, plus the logic that changes them, into one organized place.

Think of the cart badge in your header and the cart page on a different route. Both want to read the same “cart contents,” yet as a component tree they sit far apart. Passing the value down through props the whole way is painful. Stuffing it into a global variable does not work either, because changing it will not re-render the screen.

So you build a small box to hold the shared value, and only the components subscribed to that box re-render automatically when the value changes. That box is the store. Zustand lets you create one in a few lines, and the selling point is that it does it without dragging in something as heavy as useContext. The official usage is laid out in the Zustand docs.

But being lightweight has a flip side. The conventions are looser than Redux’s “write it exactly like this,” so if you do not decide the design yourself, you end up like me, throwing everything into one box until it jams. That is why I draw the boundaries first.

What goes in the store, and what stays out

My current rule of thumb is simple. “Several distant components use the same value.” “There is UI state that a URL alone struggles to express.” “I want to test the update logic in one place.” If a value matches one of those, it goes in the store.

Conversely, the product list or user list you fetched from the server does not go in. Those need caching, refetching, and a notion of staleness, and carrying all of that in a hand-rolled store leads straight to misery. Hand server-side data off to a dedicated tool, and keep only the screen’s concerns (UI state) in Zustand. That single line is the foundation for everything else.

Use caseKeep in the storeKeep outHow to brief Claude Code
Admin filterskeyword, status, page, pageSizethe full API responseSplit URL-synced fields from UI-only fields
CartSKU, quantity, display totalpayment session, inventory truthLimit which fields persist to localStorage
Auth UI statelogin dialog open/closed, “checking” indicatoraccess token, email, addressKeep PII out of the store and out of persist
Modal/toastthe open modal, the toast queuelong error logs, audit logsHold only the short text shown on screen
Optimistic updatein-flight requestId, the prior display statethe server’s final truthSpecify rollback values and conflict rules

Just writing this table first slashed the number of times I later went pale realizing “there’s personal data left in localStorage.” For the broader thinking on state management, see the guide to designing state management with Claude Code; for the React side of the workflow, see the guide to React development with Claude Code. If you want to sort out the foundation first, start there.

Defining the store in TypeScript

Here is the real thing. A minimal but complete store holding admin filters, the cart, auth UI, modals, and toasts in one place. In production you can split it across files, but when you ask Claude Code for help, having it build this shape first makes review easier. Because the types are there, trying to add a value that should not belong trips a type error.

import { create, type StateCreator } from "zustand";

export type OrderStatus = "all" | "paid" | "refunded" | "failed";
export type AuthUiStatus = "anonymous" | "checking" | "signedIn";
export type ModalId = "invite-user" | "cart-drawer" | "delete-order" | null;

export interface AdminFilters {
  keyword: string;
  status: OrderStatus;
  page: number;
  pageSize: number;
}

export interface CartLine {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

export interface AuthUi {
  status: AuthUiStatus;
  loginDialogOpen: boolean;
}

export interface Toast {
  id: string;
  kind: "success" | "error" | "info";
  message: string;
}

export interface CommerceUiState {
  filters: AdminFilters;
  cart: CartLine[];
  auth: AuthUi;
  activeModal: ModalId;
  toasts: Toast[];
  setFilters: (patch: Partial<AdminFilters>) => void;
  resetFilters: () => void;
  addToCart: (line: Omit<CartLine, "quantity">) => void;
  removeFromCart: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  setAuthStatus: (status: AuthUiStatus) => void;
  setLoginDialogOpen: (open: boolean) => void;
  openModal: (modal: Exclude<ModalId, null>) => void;
  closeModal: () => void;
  pushToast: (toast: Omit<Toast, "id">) => void;
  dismissToast: (id: string) => void;
  checkoutTotal: () => number;
}

export const initialFilters: AdminFilters = {
  keyword: "",
  status: "all",
  page: 1,
  pageSize: 25,
};

// Fall back gracefully where crypto.randomUUID is unavailable
const createToastId = () =>
  globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);

export const createCommerceUiSlice: StateCreator<CommerceUiState> = (set, get) => ({
  filters: initialFilters,
  cart: [],
  auth: { status: "anonymous", loginDialogOpen: false },
  activeModal: null,
  toasts: [],

  // When a filter changes, always reset the page to 1
  // (prevents the "empty results on a stale page number" bug)
  setFilters: (patch) =>
    set((state) => ({
      filters: {
        ...state.filters,
        ...patch,
        page: patch.page ?? 1,
      },
    })),

  resetFilters: () => set({ filters: initialFilters }),

  addToCart: (line) =>
    set((state) => {
      const current = state.cart.find((item) => item.id === line.id);
      if (!current) {
        return { cart: [...state.cart, { ...line, quantity: 1 }] };
      }

      return {
        cart: state.cart.map((item) =>
          item.id === line.id ? { ...item, quantity: item.quantity + 1 } : item,
        ),
      };
    }),

  removeFromCart: (id) =>
    set((state) => ({
      cart: state.cart.filter((item) => item.id !== id),
    })),

  // Rows whose quantity drops to zero or below are removed automatically
  updateQuantity: (id, quantity) =>
    set((state) => ({
      cart: state.cart
        .map((item) =>
          item.id === id ? { ...item, quantity: Math.max(0, quantity) } : item,
        )
        .filter((item) => item.quantity > 0),
    })),

  setAuthStatus: (status) =>
    set((state) => ({
      auth: { ...state.auth, status },
    })),

  setLoginDialogOpen: (open) =>
    set((state) => ({
      auth: { ...state.auth, loginDialogOpen: open },
    })),

  openModal: (modal) => set({ activeModal: modal }),
  closeModal: () => set({ activeModal: null }),

  pushToast: (toast) =>
    set((state) => ({
      toasts: [...state.toasts, { ...toast, id: createToastId() }],
    })),

  dismissToast: (id) =>
    set((state) => ({
      toasts: state.toasts.filter((toast) => toast.id !== id),
    })),

  checkoutTotal: () =>
    get().cart.reduce((total, item) => total + item.price * item.quantity, 0),
});

export const useCommerceUiStore = create<CommerceUiState>()(createCommerceUiSlice);

The quietly important choice here is that the store holds the UI state of auth, not auth itself. The moment you put an access token, email address, or shipping address into the store, those values become readable from everywhere on screen. Tell Claude Code up front: “No PII — personally identifiable information — in the store.” That one sentence cuts a lot of cleanup later.

Limiting re-renders with selectors

That “one toast makes the whole screen stutter” mess I opened with? This is where it comes from.

A Zustand store is a hook, so components can read it directly. But if you write useCommerceUiStore() and subscribe to the entire store, the component re-renders every time anything inside the store changes. Add a toast, and the cart badge and the filter bar all get dragged along for the ride.

What stops this is a selector. A selector is “a function that picks only the part of the store you genuinely need.” For a single value you can pass it straight through; when you bundle several values into an object, you wrap it in useShallow. useShallow does a shallow comparison and skips the re-render when the contents are the same.

import { useShallow } from "zustand/react/shallow";
import {
  useCommerceUiStore,
  type CommerceUiState,
} from "./commerce-ui-store";

// Selects only the total item count. Changes outside cart won't re-render this.
export const selectCartCount = (state: CommerceUiState) =>
  state.cart.reduce((sum, item) => sum + item.quantity, 0);

export const selectCartTotal = (state: CommerceUiState) => state.checkoutTotal();

export function CartBadge() {
  const count = useCommerceUiStore(selectCartCount);

  return <button type="button">Cart ({count})</button>;
}

export function AdminFilterSummary() {
  // When grabbing several values at once, use useShallow for a shallow compare
  const { filters, setFilters, resetFilters } = useCommerceUiStore(
    useShallow((state) => ({
      filters: state.filters,
      setFilters: state.setFilters,
      resetFilters: state.resetFilters,
    })),
  );

  return (
    <form>
      <input
        value={filters.keyword}
        placeholder="Search orders"
        onChange={(event) => setFilters({ keyword: event.currentTarget.value })}
      />
      <select
        value={filters.status}
        onChange={(event) =>
          setFilters({ status: event.currentTarget.value as CommerceUiState["filters"]["status"] })
        }
      >
        <option value="all">All</option>
        <option value="paid">Paid</option>
        <option value="refunded">Refunded</option>
        <option value="failed">Failed</option>
      </select>
      <output>Page {filters.page}</output>
      <button type="button" onClick={resetFilters}>
        Reset
      </button>
    </form>
  );
}

There is one more trap. If your selector builds a fresh array or object every call, even useShallow flags it as “a different reference every time” and the re-renders never stop. useShallow is a shallow comparison, so it checks whether the inner elements are the same reference. That is why the baseline is “pick the values you need plainly and return them,” and you do any shaping or formatting either in the component or inside a useShallow wrapper. While the UI is small you will not see the difference; on an admin screen where a table, a sidebar, and notifications all live together, having this or not noticeably changes how fast the app feels.

persist: narrow the saved fields with partialize

The persist middleware is a handy mechanism that keeps state alive across reloads. But a persist that does not narrow what it saves is a loaded gun.

You are glad the cart and filters survive. On the other hand, if you also save modal open/closed state, toasts, auth UI state, and in-flight request IDs, you reload to find a mysterious modal open or a toast you dismissed coming back from the dead. Worse is the pattern where you thoughtlessly persist the whole store and tokens or personal data sit in localStorage in plain text.

That is what partialize is for. It is “a function that extracts only the fields you want to save.” In the example below I narrow the saved fields to just filters and cart, and on the server (SSR) where there is no window, I return a safe no-op storage. The reference for each option is in the official persist middleware reference.

import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
  createCommerceUiSlice,
  type CommerceUiState,
} from "./commerce-ui-store";

// On the server (SSR) there is no window, so return a no-op storage
const noopStorage = {
  getItem: () => null,
  setItem: () => undefined,
  removeItem: () => undefined,
};

type PersistedCommerceUiState = Pick<CommerceUiState, "filters" | "cart">;

export const usePersistedCommerceUiStore = create<CommerceUiState>()(
  persist(createCommerceUiSlice, {
    name: "commerce-ui-v1",
    version: 1,
    storage: createJSONStorage(() =>
      typeof window === "undefined" ? noopStorage : window.localStorage,
    ),
    // Only filters and cart are safe to save. Never include tokens or PII.
    partialize: (state): PersistedCommerceUiState => ({
      filters: state.filters,
      cart: state.cart,
    }),
  }),
);

In environments with SSR, like Next.js or Astro, there is one more thing to watch. The HTML the server built obviously cannot read localStorage, so it renders an empty cart; the browser then restores three items from localStorage, and a mismatch appears. That is a hydration mismatch — it logs a console warning or makes the display flicker. For anything that depends on restored values, like a badge or a total, render it after mount, or carve it out into a region the server does not render. Bake that into your instructions to Claude Code too, and you are safe.

Async and optimistic updates: don’t lose to stale responses

An optimistic update is a technique where you change the screen first without waiting for the server to confirm. It suits actions with a low failure rate where you want to cut the wait: follow, like, changing a cart quantity.

But there is a trap. When async operations race, the stale response from an earlier request can come back later and overwrite newer state. On rapid clicks, when you switch tabs, when the connection is slow. The minimum defense is to hold a request ID and to save the value you roll back to on failure.

import { create } from "zustand";

interface Profile {
  id: string;
  name: string;
  isFollowing: boolean;
}

interface ProfileState {
  profiles: Record<string, Profile>;
  followRequestIds: Record<string, string>;
  setProfile: (profile: Profile) => void;
  followOptimistically: (profileId: string) => Promise<void>;
}

// Remove a specific key from an object and return a new object
const removeKey = <T,>(record: Record<string, T>, key: string) => {
  const { [key]: _removed, ...rest } = record;
  return rest;
};

async function updateFollowOnServer(profileId: string, follow: boolean) {
  const response = await fetch(`/api/profiles/${profileId}/follow`, {
    method: follow ? "PUT" : "DELETE",
  });

  if (!response.ok) {
    throw new Error(`Follow update failed: ${response.status}`);
  }
}

export const useProfileStore = create<ProfileState>((set, get) => ({
  profiles: {},
  followRequestIds: {},

  setProfile: (profile) =>
    set((state) => ({
      profiles: { ...state.profiles, [profile.id]: profile },
    })),

  followOptimistically: async (profileId) => {
    const before = get().profiles[profileId];
    if (!before) {
      throw new Error("Profile not found");
    }

    // ID for this operation. Used later to confirm "am I still the latest?"
    const requestId = `${profileId}-${Date.now()}`;

    // Update the screen first (assume success optimistically)
    set((state) => ({
      profiles: {
        ...state.profiles,
        [profileId]: { ...before, isFollowing: true },
      },
      followRequestIds: {
        ...state.followRequestIds,
        [profileId]: requestId,
      },
    }));

    try {
      await updateFollowOnServer(profileId, true);
    } catch (error) {
      set((state) => {
        // If a newer request is in flight, yield instead of rolling back
        if (state.followRequestIds[profileId] !== requestId) return state;

        return {
          profiles: { ...state.profiles, [profileId]: before },
          followRequestIds: removeKey(state.followRequestIds, profileId),
        };
      });
      throw error;
    }

    set((state) => {
      if (state.followRequestIds[profileId] !== requestId) return state;

      return {
        followRequestIds: removeKey(state.followRequestIds, profileId),
      };
    });
  },
}));

When you have Claude Code write this kind of async, always specify “what to roll back on failure,” “whether rapid clicks on the same ID are allowed,” and “whether to show a toast after rolling back.” Leave that vague and you get back a beautiful-looking async/await that, under real user behavior, loses easily to double submits and stale responses. Pretty and correct are two different things.

How to choose between Redux, Context, and Zustand

“Is Zustand the right call, or Redux, or Context?” I get asked a lot. Here is how I split it.

The Context API is plenty for distributing values that barely change, like theme or locale. But put a frequently changing value in Context, and every component reading that Context re-renders across the board. If you want fine-grained re-render control, Context is the wrong tool.

Redux’s strength is that actions and reducers let you trace the flow of updates strictly. When a large team wants to fully track “who changed what state, and how,” or wants time-travel debugging, it is still a strong choice. The price is the volume of boilerplate.

Zustand sits in between: minimal boilerplate, while selectors still let you narrow re-renders. For small-to-medium projects where you want “lightweight, but no compromise on speed,” it is my first pick. The table below is my rough rubric.

DimensionContext APIRedux (Toolkit)Zustand
Amount to writeLowHighLow
Re-render controlWeakPossible via selectorsStrong via selectors
Ease of tracking updatesLowHigh (history, devtools)Medium (devtools available)
Suited scaleSmall (near-immutable values)Medium-large, big teamsSmall-medium
Learning costLowHighLow

One more thing: data fetched from the server is a separate budget no matter which you pick. Leave list fetching, caching, and refetching to a dedicated library, and keep only the screen’s UI state in Zustand. There are also cases where you want fine-grained, atom-style management, which I cover in a separate article. If you would rather decide from the whole picture of state management, reading the state management design guide first will make this call faster.

Copy-paste runnable: testing the store with Vitest

A Zustand store can be tested without rendering React. That is the quiet upside of a lightweight library: because it is close to a plain object, you can verify the logic alone, fast. There is exactly one knack — reset to the initial state before each test. Forget it, and the previous test’s cart lingers and you puzzle over mysterious failures.

The test below works if you drop it straight into your project (commerce-ui-store.ts is the store definition above).

import { beforeEach, describe, expect, it } from "vitest";
import { useCommerceUiStore } from "./commerce-ui-store";

// Capture the initial state right after startup
const initialState = useCommerceUiStore.getInitialState();

beforeEach(() => {
  // Always reset the store to a clean slate before each test
  // (the second argument, true, fully replaces the state)
  useCommerceUiStore.setState(initialState, true);
});

describe("commerce ui store", () => {
  it("adds the same product twice, quantity becomes 2, and the total is computed", () => {
    const store = useCommerceUiStore.getState();

    store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });
    store.addToCart({ id: "sku-1", name: "Workshop", price: 1200 });

    expect(useCommerceUiStore.getState().cart[0]?.quantity).toBe(2);
    expect(useCommerceUiStore.getState().checkoutTotal()).toBe(2400);
  });

  it("resets the page to 1 when a filter changes", () => {
    useCommerceUiStore.getState().setFilters({ page: 4 });
    useCommerceUiStore.getState().setFilters({ keyword: "refund" });

    expect(useCommerceUiStore.getState().filters).toMatchObject({
      keyword: "refund",
      page: 1,
    });
  });

  it("retains auth UI and toasts as intended", () => {
    useCommerceUiStore.getState().setAuthStatus("signedIn");
    useCommerceUiStore.getState().pushToast({
      kind: "success",
      message: "Saved",
    });

    expect(useCommerceUiStore.getState().auth.status).toBe("signedIn");
    expect(useCommerceUiStore.getState().toasts).toHaveLength(1);
  });
});

The good thing about tests is that they pin down not just “does the code run” but “are the design promises kept.” A filter change resets the page to 1, quantity zero removes the row, a failed optimistic update rolls back. Writing specs like these into the test names first keeps behavior from drifting even after you have Claude Code edit it later. Ask for implementation and review in separate messages, and for the review fix the lens: “Look only at the Zustand parts of this diff, critically, and call out values that should not be in the store, selectors that are too broad, and persist leaks.” That keeps you from the habit of softly approving code you wrote yourself.

FAQ

Q. One store per app, or several? A. For a small app, one is enough. As features grow, splitting by concern — one for the cart, one for the admin screen — keeps things readable. Zustand lets you create multiple stores, so there is no need to force-maintain one giant one.

Q. When do I actually need useShallow? A. When a selector returns several values bundled into an object or array. If it returns a single value (a number or string), you do not need it. Returning several without it gets flagged as a different reference every time, and the re-renders never stop.

Q. What if I want to change the shape of persisted data? A. Bump version and convert old data with migrate. Changing name (the storage key) treats it as a different thing and leaves the old data behind, so when the shape changes, migrating explicitly via version is the safe move.

Q. Can I put server-fetched data in Zustand too? A. As a rule, no. Server data that needs caching and refetching belongs to a dedicated library; keep only the screen’s UI state in Zustand. Mix them and you end up hand-rolling staleness checks and loading management.

Q. Is it worth migrating from Redux to Zustand? A. If the motivation is reducing boilerplate or lightening re-renders, yes, it is worth it. If you depend on a strict update history or time-travel debugging, keeping Redux is a defensible call too.

What happened when I actually tried this

After the opening “one toast stutters the screen” incident, the first thing I did was not add a feature — it was write a table before putting anything in state. A table splitting “values that are safe to keep” from “values that are dangerous to keep.”

Starting from that table, I put only UI state in the store, read everything through selectors in components, and narrowed persist with partialize to just filters and cart. Once I held that line consistently, adding notifications left the table quiet, and personal data disappeared from localStorage.

The code in this article was checked on June 7, 2026 against the Zustand docs for create, persist, and useShallow and the testing approach, and shaped so it copies straight into TypeScript. What I keep feeling after running this on real projects is that the difference in design shows up not in how short the code is, but in how clear the boundaries are. Spending the first ten minutes writing the table is far faster than spending half a day later scrubbing localStorage.

If you want to align your team on the criteria for state-management decisions, or take inventory using an existing admin screen as the subject, we can sort it out together in training and consulting. For starters, run the store and tests above as-is, and add one “values to keep out” table to your own screen. That is where it begins.

#Claude Code #Zustand #React #state management #TypeScript
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.