Updated June 2026Cookbook18 min read

Zustand skill: 10 store patterns you can ship

Ten real Zustand patterns — a typed store with selectors, a sliced store, persist with partialize, immer for nested updates, an async action, a subscribeWithSelector effect, a Redux migration, transient 60fps reads, devtools, and a Vitest suite — each as a single Claude prompt with the exact store code it produces.

One thing to set expectations: this skill is LobeHub’s house style, not a neutral Zustand primer. It has strong opinions — verb-form public actions, an internal_ prefix for the rest, one slice per feature, a deliberate reducer-vs-set choice. Zustand’s own README says it “isn’t opinionated.” That gap is precisely what a style-guide skill fills: it stops you re-litigating store structure on every feature.

Already know what skills are? Skip to the cookbook. First time? Read the explainer then come back. Need the install? It’s on the /skills/zustand page.

Editorial illustration: a TypeScript store file on the left flowing into a tree of selector-subscribed React components on the right, connected by a luminous teal arc, on a midnight navy background.
On this page · 21 sections
  1. What this skill does
  2. The cookbook
  3. Install + README
  4. Watch it built
  5. 01 · Typed store with actions and selectors
  6. 02 · Slice pattern for a large store
  7. 03 · persist middleware with partialize (localStorage)
  8. 04 · immer middleware for nested updates
  9. 05 · Async action that fetches into the store
  10. 06 · subscribeWithSelector for derived side-effects
  11. 07 · Migrate a Redux or Context store to Zustand
  12. 08 · Transient updates with getState to avoid re-renders
  13. 09 · devtools middleware with named actions
  14. 10 · Test a store with Vitest
  15. Community signal
  16. The contrarian take
  17. Real stores
  18. Gotchas
  19. Pairs well with
  20. FAQ
  21. Sources

What this skill actually does

Sixty seconds of context before the cookbook — what the Zustand skill shapes, what Claude returns when you invoke it, and the one thing it does NOT do for you.

What this skill actually does

Zustand state management guide. Use when working with store code, implementing actions, managing state, or creating slices.

lobehub, the skill author · /skills/zustand

What Claude returns

When triggered, Claude writes Zustand store code in LobeHub's house style: a typed state interface plus an action interface combined into the store type, public actions in verb form (`sendMessage`), the rest behind an `internal_` prefix, and one slice per feature composed into a single `create` call. It picks the reducer-style dispatch for object lists, maps, and optimistic updates, and plain `set` for booleans and single fields. State follows the naming convention — `topicMaps`, `activeTopicId`, `topicsInit` — so the shape reads the same across every store.

What it does NOT do

It does not install Zustand or pick your middleware stack — `npm i zustand` first, then trigger it. And it shapes code to LobeHub's conventions, so adapt the helper names to your project rather than expecting a drop-in config.

How you trigger it

Refactor this store into slices.Add an optimistic create action to the topic store.Convert my Redux auth slice to a Zustand store.

Cost when idle

~100 tokens at idle (the skill name + description in the system prompt). The full convention guide and action-pattern references load only when a store task triggers it.

The cookbook

Each entry is a Zustand task you could ship today. They run roughly from the store outward — the early ones type and slice the store, the middle ones add the middleware (persist, immer, subscribeWithSelector, devtools), and the later ones handle the awkward cases (Redux migration, transient 60fps reads, isolated Vitest tests). Every entry pairs with a skill or MCP server you already have on mcp.directory.

The code in each snippet follows the skill’s real conventions, lifted from LobeHub’s own store guide: typed state and action interfaces, verb-form public actions, init flags like topicsInit, and the reducer-vs-set split. Swap those names for yours and the shape carries over to any Zustand project.

Install + README

If the skill isn’t on your machine yet, here’s the one-liner. The full install panel (Claude Code, Codex, Copilot, Antigravity variants) is on the skill page — the same UI’s embedded below.

One-line install · by lobehub

Open skill page

Install

mkdir -p .claude/skills/zustand && curl -L -o skill.zip "https://mcp.directory/api/skills/download/626" && unzip -o skill.zip -d .claude/skills/zustand && rm skill.zip

Installs to .claude/skills/zustand

Watch it built

A complete Zustand walkthrough in one sitting — store creation, selectors, async actions, and middleware. Useful before the cookbook because it anchors the API surface (create, selectors, persist, immer) every prompt below picks up.

01

Typed store with actions and selectors

Scaffold a fully typed store: a state interface, an actions interface, the combined `create` call, and named selector hooks so components subscribe to one slice of state instead of the whole object.

ForAnyone starting a new Zustand store and tired of re-typing the same shape.

The prompt

Create a typed Zustand store in `src/store/cart/index.ts`. State: `items: CartItem[]`, `couponCode: string | null`. Actions in verb form: `addItem(item)`, `removeItem(id)`, `applyCoupon(code)`, `clear()`. Type it with a `CartState` interface and a `CartAction` interface combined as the store type, and use `create<CartState & CartAction>()`. Export per-field selector hooks — `useCartItems`, `useCartTotal` (derived from items) — so a component reading the total never re-renders when the coupon changes.

What slides.md looks like

interface CartState {
  items: CartItem[];
  couponCode: string | null;
}
interface CartAction {
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clear: () => void;
}
export const useCartStore = create<CartState & CartAction>()((set) => ({
  items: [],
  couponCode: null,
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
  removeItem: (id) =>
    set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
  clear: () => set({ items: [], couponCode: null }),
}));

// Selectors keep re-renders narrow.
export const useCartItems = () => useCartStore((s) => s.items);
export const useCartTotal = () =>
  useCartStore((s) => s.items.reduce((sum, i) => sum + i.price, 0));

One-line tweak

Wrap the selector in `useShallow` from `zustand/react/shallow` when it returns a new array or object, so the component compares by contents, not reference.

02

Slice pattern for a large store

Split one growing store into focused slices — each its own `StateCreator` — then compose them into a single `create` call. This is the LobeHub house pattern: one slice per feature, combined into the store type.

ForTeams whose single store file has crossed 300 lines and keeps growing.

The prompt

Refactor `src/store/chat/index.ts` into slices. Create `messageSlice` and `inputSlice`, each typed as `StateCreator<ChatStore, [], [], MessageSlice>` returning its own state plus actions. Build the combined store with `create<ChatStore>()((...a) => ({ ...messageSlice(...a), ...inputSlice(...a) }))`. Keep public actions in verb form (`sendMessage`, `editInput`) and follow LobeHub's naming: ID arrays like `messageEditingIds`, active identifiers like `activeMessageId`, init flags like `messagesInit`.

What slides.md looks like

export interface MessageSlice {
  messages: Message[];
  activeMessageId: string | null;
  sendMessage: (text: string) => void;
}
export const messageSlice: StateCreator<
  ChatStore, [], [], MessageSlice
> = (set, get) => ({
  messages: [],
  activeMessageId: null,
  sendMessage: (text) =>
    set((s) => ({ messages: [...s.messages, build(text)] })),
});

export type ChatStore = MessageSlice & InputSlice;
export const useChatStore = create<ChatStore>()((...a) => ({
  ...messageSlice(...a),
  ...inputSlice(...a),
}));

One-line tweak

When a slice needs to read another slice, call `get()` inside the action instead of importing the other slice — the combined store gives every slice the same `get`.

03

persist middleware with partialize (localStorage)

Add the `persist` middleware so a slice of state survives reloads, but use `partialize` to whitelist only the fields worth saving — never persist transient flags or derived data.

ForAnyone storing UI preferences, a draft, or auth state across page loads.

The prompt

Add `persist` to `src/store/settings/index.ts`. Persist only `theme` and `sidebarCollapsed` — exclude the transient `isSaving` flag and `lastSyncedAt` via `partialize`. Use `createJSONStorage(() => localStorage)`, set `name: 'settings'` and `version: 1`, and add a `migrate` stub that maps version 0 to 1. Note in a comment that reads before hydration finishes return the initial state, and show how to gate on `useSettingsStore.persist.hasHydrated()`.

What slides.md looks like

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: 'system',
      sidebarCollapsed: false,
      isSaving: false,
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'settings',
      version: 1,
      storage: createJSONStorage(() => localStorage),
      // Only these keys reach localStorage.
      partialize: (s) => ({
        theme: s.theme,
        sidebarCollapsed: s.sidebarCollapsed,
      }),
      migrate: (persisted, version) =>
        version === 0 ? { ...(persisted as object) } : persisted,
    },
  ),
);

One-line tweak

Swap `localStorage` for `sessionStorage` in `createJSONStorage` to scope the persisted state to a single tab session instead of the whole browser.

04

immer middleware for nested updates

Wrap the store in the `immer` middleware so deeply nested updates read like direct mutations — no spread pyramids — while Zustand still gets an immutable next state under the hood.

ForAnyone updating state three levels deep and tired of nested spreads.

The prompt

Convert `src/store/board/index.ts` to use the `immer` middleware so I can mutate nested draft state directly. The state is a Kanban board: `columns: Record<string, { title: string; cardIds: string[] }>` and `cards: Record<string, Card>`. Add `moveCard(cardId, fromCol, toCol, index)` that splices the id out of one column's `cardIds` and into another's by index — written as plain draft mutations, not spreads. Keep the middleware order: `immer` is the innermost wrapper.

What slides.md looks like

export const useBoardStore = create<BoardStore>()(
  immer((set) => ({
    columns: {},
    cards: {},
    moveCard: (cardId, fromCol, toCol, index) =>
      set((draft) => {
        const from = draft.columns[fromCol].cardIds;
        from.splice(from.indexOf(cardId), 1);
        draft.columns[toCol].cardIds.splice(index, 0, cardId);
      }),
  })),
);

One-line tweak

Stack `devtools(immer(...))` to watch each mutation land in Redux DevTools with a readable action label — see use case 9.

05

Async action that fetches into the store

Write an async action that owns its own loading and error flags, calls a service, and commits the result — the standard data-fetch shape Zustand handles without any extra middleware.

ForAnyone fetching list data and tired of duplicating loading/error state in components.

The prompt

Add an async `fetchTopics()` action to `src/store/topic/index.ts`. It sets `topicsLoading = true`, calls `topicService.getTopics()`, commits the result to `topics`, sets `topicsInit = true`, and clears the loading flag in a `finally`. On error, store the message in `topicsError`. Follow LobeHub's init-flag convention so the UI can distinguish 'never loaded' from 'loaded but empty'. Keep the action self-contained — no try/catch leaking into components.

What slides.md looks like

fetchTopics: async () => {
  set({ topicsLoading: true, topicsError: null });
  try {
    const topics = await topicService.getTopics();
    set({ topics, topicsInit: true });
  } catch (e) {
    set({ topicsError: (e as Error).message });
  } finally {
    set({ topicsLoading: false });
  }
},

// Component reads three flags, owns none of the logic:
const loading = useTopicStore((s) => s.topicsLoading);
const init = useTopicStore((s) => s.topicsInit);

One-line tweak

Dedupe concurrent calls by checking a `topicsLoading` guard at the top of the action — return early if a fetch is already in flight.

06

subscribeWithSelector for derived side-effects

Run a side-effect when one slice of state changes — analytics, localStorage sync, a websocket ping — using the `subscribeWithSelector` middleware so the subscription fires only on the field you care about.

ForAnyone wiring an effect to a state change without dragging it through a component.

The prompt

Add the `subscribeWithSelector` middleware to `src/store/player/index.ts` and register a subscription outside React that fires only when `currentTrackId` changes — not on every state update. In the listener, log a `track_played` analytics event with the new and previous id. Show the `equalityFn` third argument and return the unsubscribe function so a teardown can call it. Keep the subscription in a module-level `initPlayerEffects()` the app calls once.

What slides.md looks like

export const usePlayerStore = create<PlayerStore>()(
  subscribeWithSelector((set) => ({
    currentTrackId: null,
    play: (id) => set({ currentTrackId: id }),
  })),
);

export function initPlayerEffects() {
  return usePlayerStore.subscribe(
    (s) => s.currentTrackId,
    (id, prevId) => {
      if (id) analytics.track('track_played', { id, prevId });
    },
  );
}

One-line tweak

Pass `{ fireImmediately: true }` as the fourth option so the listener also runs once on subscribe with the current value — handy for hydration.

07

Migrate a Redux or Context store to Zustand

Port a Redux slice (or an over-grown Context provider) into a Zustand store — reducers become actions, `useSelector` becomes a selector hook, and the Provider wrapper disappears entirely.

ForTeams carrying Redux boilerplate or a Context that re-renders the whole tree.

The prompt

Migrate the Redux `authSlice` in `src/redux/auth.ts` to a Zustand store at `src/store/auth/index.ts`. Map each reducer case to a verb-form action: `login.fulfilled` becomes an async `login(creds)`, `logout` becomes `logout()`. Replace `useSelector((s) => s.auth.user)` call sites with a `useAuthUser` selector hook. Remove the `<Provider>` and the dispatch plumbing. Do not keep both stores live at once — port the slice, update its call sites, delete the Redux file in the same pass.

What slides.md looks like

// Before (Redux): dispatch(login(creds)); useSelector(s => s.auth.user)
// After (Zustand): one store, no provider, no dispatch.
export const useAuthStore = create<AuthStore>()((set) => ({
  user: null,
  status: 'idle',
  login: async (creds) => {
    set({ status: 'loading' });
    const user = await authService.login(creds);
    set({ user, status: 'authed' });
  },
  logout: () => set({ user: null, status: 'idle' }),
}));
export const useAuthUser = () => useAuthStore((s) => s.user);

One-line tweak

Migrate one slice per pull request, not the whole store — the two libraries coexist fine during the transition as long as no single slice lives in both.

08

Transient updates with getState to avoid re-renders

Read and write store state outside React — in an animation loop, a scroll handler, or a high-frequency event — using `getState`/`setState` directly so a 60fps update never triggers a component render.

ForAnyone wiring fast events (mouse, scroll, rAF) to state without re-rendering on each tick.

The prompt

In `src/store/pointer/index.ts`, expose the store's `getState`/`setState` so a pointermove handler can write `x`/`y` at 60fps without re-rendering React. Show a component that subscribes transiently — it reads the live value via `usePointerStore.subscribe` into a ref and updates a DOM node directly, instead of calling the selector hook. Note where the trade-off lands: no re-render, but the value is read imperatively, so it's for visuals, not for state React must reconcile.

What slides.md looks like

// Write at 60fps, zero React renders:
window.addEventListener('pointermove', (e) => {
  usePointerStore.setState({ x: e.clientX, y: e.clientY });
});

// Read transiently into a ref — no selector subscription:
function Cursor() {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(
    () =>
      usePointerStore.subscribe((s) => {
        if (ref.current)
          ref.current.style.transform = `translate(${s.x}px,${s.y}px)`;
      }),
    [],
  );
  return <div ref={ref} className="cursor" />;
}

One-line tweak

Pair this with `subscribeWithSelector` (use case 6) when you only want the transient effect to run for one field, not on every store change.

09

devtools middleware with named actions

Wrap the store in the `devtools` middleware so every state change shows up in the Redux DevTools extension with a readable action name — turning Zustand's anonymous `set` calls into a labeled timeline.

ForAnyone debugging why state changed and tired of staring at anonymous setState entries.

The prompt

Add the `devtools` middleware to `src/store/chat/index.ts` and give every `set` call a third action-name argument so the Redux DevTools timeline reads 'sendMessage', 'editInput', etc. instead of anonymous. Set `{ name: 'chat-store' }` so it gets its own DevTools instance, and gate it behind `process.env.NODE_ENV !== 'production'` so the wrapper is stripped in prod. Show the correct middleware order when combined with persist: `devtools(persist(...))`.

What slides.md looks like

export const useChatStore = create<ChatStore>()(
  devtools(
    (set) => ({
      messages: [],
      sendMessage: (text) =>
        set(
          (s) => ({ messages: [...s.messages, build(text)] }),
          false,
          'sendMessage', // labeled in Redux DevTools
        ),
    }),
    { name: 'chat-store', enabled: process.env.NODE_ENV !== 'production' },
  ),
);

One-line tweak

Combine with persist as `devtools(persist(store, persistOpts), devtoolsOpts)` — devtools always wraps outermost so it sees the rehydrated state too.

10

Test a store with Vitest

Unit-test a store's actions in isolation — no React, no render — by calling `getState`, invoking actions, and asserting the next state, with a `beforeEach` reset so tests don't leak state into each other.

ForAnyone who wants store logic covered without mounting a single component.

The prompt

Write Vitest tests for `useCartStore` in `src/store/cart/index.test.ts`. Capture the initial state and reset it in `beforeEach` with `useCartStore.setState(initialState, true)`. Test `addItem` appends, `removeItem` filters by id, and the `useCartTotal` selector sums prices — each by calling actions through `getState()` and asserting the result. No `@testing-library/react`; this is pure store logic. Mock the coupon service with `vi.fn()` for the async `applyCoupon` path.

What slides.md looks like

import { useCartStore } from './index';

const initial = useCartStore.getState();
beforeEach(() => useCartStore.setState(initial, true));

it('addItem appends an item', () => {
  useCartStore.getState().addItem({ id: 'a', price: 10 });
  expect(useCartStore.getState().items).toHaveLength(1);
});

it('removeItem filters by id', () => {
  const { addItem, removeItem } = useCartStore.getState();
  addItem({ id: 'a', price: 10 });
  removeItem('a');
  expect(useCartStore.getState().items).toEqual([]);
});

One-line tweak

Add a global `afterEach` that resets every store via Zustand's mock (`__mocks__/zustand.ts`) so you reset all stores at once instead of per-file.

Community signal

Three voices on why developers reach for Zustand. The first is the library’s own “not opinionated” framing — the exact gap this skill fills — the second is the re-render-and-immer endorsement, the third is the selector-subscription model the cookbook leans on.

A small, fast and scalable bearbones state-management solution using simplified flux principles. Has a comfy API based on hooks, isn't boilerplatey or opinionated.

pmndrs (Zustand README) · Blog

The library's own first-party framing in its README — the 'not opinionated' line is the exact gap a house-style skill fills.

Source
Zustand is great … It gets out of the way in terms of boilerplate, has a good solution for avoiding unnecessary re-renders, has tooling for deep state updates without messy syntax (immer) and strikes an overall good balance between powerful and easy to use.

halotrope · Hacker News

HN 'favorite front-end state management' thread. Names the re-render and immer wins directly (quote trimmed at the ellipsis; verbatim otherwise).

Source
It's a tiny library (v4.1.4 is 1.1kB Minified + Gzipped) that provides a simple API to create global state stores and subscribe to them via selectors.

Dominik Dorfmeister (TkDodo) · Blog

The React Query maintainer's 'Working with Zustand' post — the selector-subscription model use case 1 leans on.

Source

The contrarian take

Not everyone thinks an unopinionated store is a feature. The most honest critique — and it comes from a self-described fan — is from Yodiddlyyo on r/reactjs:

Most devs are bad at architecture. If you leave structuring up to the dev, it will inevitably turn into garbage. The only way is to force structure. And I say this as someone who loves jotai and zustand.

Yodiddlyyo · Reddit

r/reactjs — the 'unopinionated stores rot at scale' argument, from someone who loves Zustand and Jotai.

Source

This is the strongest case for a skill like this one, not against Zustand. The critique is exactly right — an unopinionated store with no imposed structure rots at scale. That is the entire reason the LobeHub skill exists: it forces structure Zustand itself won't. Verb-form public actions, an `internal_` prefix for the rest, one slice per feature, init flags to tell 'never loaded' from 'empty'. The library stays minimal; the skill supplies the discipline.

One comparison on intent worth naming. People searching “zustand mcp” often expect a server to connect to — but Zustand is a client-side library, so there isn’t a database-style MCP for it. What complements the skill is a docs MCP like Context7, which pulls the current Zustand documentation into the prompt, or React Bits for components that consume a store. The trade-off is the usual skill-vs-MCP one: the skill is ~100 idle tokens and shapes the code you write; an MCP’s tool schemas load every turn. Use the skill to author the store, reach for a docs MCP the moment the API has moved under you.

Real stores built on Zustand

Concrete examples from public projects. Most don’t use the LobeHub skill specifically — they’re here to show what a production-grade Zustand store looks like, so you have a target shape in mind when you write the prompt. The last one literally is the codebase this skill was extracted from.

Gotchas (the four that bite)

Sourced from the Zustand docs and the conventions baked into the LobeHub Zustand skill.

Returning an object from a selector re-renders every time

A selector like (s) => ({ a: s.a, b: s.b }) builds a new object on each call, so the default reference check always fails and the component re-renders on every store update. Wrap it in `useShallow` from zustand/react/shallow to compare by contents. The skill writes per-field selectors to sidestep this entirely.

persist hydrates after the first render

The persist middleware reads localStorage on the client only. The first render uses the initial state, so anything gated on persisted state can flash or mismatch — and in Next.js you get a hydration warning. Gate on `useStore.persist.hasHydrated()` or render the dependent UI from an effect.

Middleware order is not arbitrary

Wrap inner to outer: devtools(persist(immer(store))). immer must be innermost so it sees raw set calls; devtools wants to be outermost so it captures rehydrated and persisted state. Get the order wrong and you'll see actions logged without their immer-applied changes, or a missing rehydrate event.

Class actions and old slice objects must not coexist

When you migrate a store to the skill's class-based action pattern, don't keep both the old action object and the new class active at once — they'll fight over the same state keys. The skill's rule is explicit: port the slice, update its call sites, and delete the old one in the same pass.

Pairs well with

Curated to match the cookbook’s actual integrations: the React and TypeScript skills the store feeds (react, typescript, nextjs-developer, react-modernization) plus the data and docs MCP servers the async, transient, and migration use cases lean on.

Two posts that compose well with this cookbook: What are Claude Code skills? covers the underlying mechanism, and the Drizzle skill cookbook is the data-layer sibling from the same author — same house-style approach, applied to your database instead of your store. For the React refactor side, see the React Doctor write-up.

Frequently asked questions

What is the Zustand skill, and what does it actually do?

It's a style guide that teaches Claude how LobeHub writes Zustand code. When you ask for a store, a slice, or an action, the skill steers the output toward consistent conventions: a typed state interface plus an action interface, public actions in verb form, internal ones behind an `internal_` prefix, one slice per feature composed with `create`, and a deliberate choice between the reducer-style dispatch and a plain `set`. Zustand's own README says it 'isn't opinionated' — this skill supplies the opinions, which is the whole point of it.

Zustand skill vs a Zustand MCP — which should I use?

They do different jobs, and there isn't really a dedicated 'Zustand MCP' the way there's a Postgres one — Zustand is a client-side library, so there's nothing for a server to connect to. What people in the 'zustand mcp' search are usually after is a docs MCP like `context7` that pulls live Zustand documentation into the prompt, or `reactbits` for ready-made React components that consume a store. Those load tool schemas every turn. The skill costs ~100 tokens at idle and shapes the code you write. Use the skill to author the store; reach for a docs MCP when an API has shifted and you want the model reading the current docs.

Does the Zustand skill work in Claude Code, Cursor, and Codex?

Yes. Agent skills are plain folders with a SKILL.md, so any agent that reads `.claude/skills` (or its own equivalent) picks them up — Claude Code, Cursor, Codex, and Copilot all do. The install panel below has a tab per client. The skill content is identical across them; only the install path differs.

Is this skill tied to LobeHub's codebase, or can I use it on any project?

The conventions come from LobeHub's LobeChat monorepo — the class-based actions, the `flattenActions` helper, the `internal_dispatch` methods, and the `src/store/**` layout are theirs. The patterns transfer cleanly: typed interfaces, verb-form actions, one slice per feature, init flags, reducer-vs-set. You'll swap their helper names for your own. Treat it as a strong default to adapt, not a drop-in config for a fresh repo — exactly how the Drizzle skill from the same author works.

Why does the skill use class-based actions and flattenActions instead of plain action objects?

Because at LobeChat's scale — many stores, each with public, internal, and dispatch tiers — plain action objects sprawl. The skill organizes each tier as a class with `#private` fields, exports a `create*Slice` helper that returns the instance, and composes them with a `flattenActions` utility so the store type stays explicit. For a small store you don't need this; the plain `create((set) => ({ ... }))` shape in use cases 1 and 3 is fine. The class pattern earns its keep once one store grows past a few hundred lines.

How do I stop Zustand selectors from re-rendering on every store change?

Subscribe to the narrowest slice you need, not the whole store. `useStore((s) => s.items)` re-renders only when `items` changes; `useStore((s) => s)` re-renders on every update. When a selector returns a new array or object each call, wrap it in `useShallow` from `zustand/react/shallow` so the comparison is by contents, not reference. For 60fps updates that shouldn't render at all, read transiently with `getState`/`subscribe` into a ref — that's use case 8.

Does persist hydrate correctly with Next.js server rendering?

It needs care. The `persist` middleware reads from localStorage on the client only, so the first server render uses the initial state and the first client render can mismatch it — React logs a hydration warning. Gate UI that depends on persisted state on `useStore.persist.hasHydrated()`, or render it after an effect runs. Use case 3 sets up `partialize`, `version`, and `migrate`; the `nextjs-developer` skill pairs with it for the SSR-specific hydration handling.

Sources

Primary

Community

Critical and contrarian

Internal

Keep reading