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.

On this page · 21 sections▾
- What this skill does
- The cookbook
- Install + README
- Watch it built
- 01 · Typed store with actions and selectors
- 02 · Slice pattern for a large store
- 03 · persist middleware with partialize (localStorage)
- 04 · immer middleware for nested updates
- 05 · Async action that fetches into the store
- 06 · subscribeWithSelector for derived side-effects
- 07 · Migrate a Redux or Context store to Zustand
- 08 · Transient updates with getState to avoid re-renders
- 09 · devtools middleware with named actions
- 10 · Test a store with Vitest
- Community signal
- The contrarian take
- Real stores
- Gotchas
- Pairs well with
- FAQ
- 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 pageInstall
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.zipInstalls 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.
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.
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`.
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.
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.
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.
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.
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.
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.
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.
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.
“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).
“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.
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.
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.
- React Flow (xyflow) — node-based UI library that uses Zustand internally and documents it as the recommended store
- react-three-fiber (pmndrs) — React renderer for three.js that drives reactive internal renderer state with Zustand
- Plate (udecode) — rich-text editor framework that embeds Zustand for editor and plugin store state
- pmndrs ecosystem (drei and friends) — the team that authors Zustand uses it as the default state layer across its React-3D stack
- Daishi Kato (@dai_shi) — the author's own account of Zustand's production design goals: small implementation, small bundle
- LobeHub — the open-source LobeChat codebase this skill was extracted from, a class-action sliced Zustand store at scale
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.
Related skills
Related MCP servers
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
- LobeHub zustand skill — SKILL.md + action-pattern and slice-organization references
- Zustand official documentation
- pmndrs/zustand — README, middleware, and TypeScript guide
- Zustand testing guide (Vitest, store reset mock)
- TkDodo — Working with Zustand (selectors, atomic state)
Community
- pmndrs (Zustand README) — Blog
- halotrope — Hacker News
- Dominik Dorfmeister (TkDodo) — Blog
- cehrlich — Hacker News
- Daishi Kato (@dai_shi, Zustand author) — Blog
- sleepy_roger — Reddit
Critical and contrarian
Internal