Zustand State skill for Codex
Small stores, narrow selectors, versioned persistence. Codex first decides whether state belongs in Zustand at all, then writes typed v5 stores with grouped actions and exported selector hooks so components subscribe narrowly. It fixes the v5 'Maximum update depth' selector trap with useShallow, versions persisted state with migrations, swaps global singletons for per-request providers under SSR, and resets stores between tests.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- zustand-state Installs to ~/.agents/skills/zustand-state/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install zustand-state The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project zustand-state Installs to .agents/skills/zustand-state/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/zustand-state.zipunzip -o zustand-state.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) zustand-state PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $zustand-state, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs Zustand v5 stores for React client state — typed create with the curried form, slices, atomic selectors and useShallow, actions kept in the store, persist with partialize, version and migrate, devtools and immer middleware, per-request stores for Next.js SSR, and test resets. Use when adding global client state, when a Zustand component re-renders too often or throws "Maximum update depth exceeded", when persisting state to localStorage, when splitting a large store, or when using Zustand with server rendering.
Use it when
- Adding shared client-side state to a React app
- Components re-render too often from store subscriptions
- Persisting state to localStorage with future schema changes
- Using Zustand with server rendering or in tests
Use something else when
- The state is server data fetched from an API: use tanstack-query
- The codebase is standardized on Redux: use redux-toolkit
How to check the result
Highlight updates shows only subscribers of a changed field re-rendering, persisted state from an older version migrates on reload without hydration warnings, and tests pass in random order.
The mistake it prevents
Selecting a freshly built object or array without useShallow, which in Zustand v5 triggers an infinite render loop.
Done well means: the store holds client state only (UI, drafts, session preferences), components subscribe to the smallest value they render, actions live next to the state they change, persisted state is versioned, and SSR apps never share one store instance across requests.
Before you start
- Read
AGENTS.mdand checkzustandinpackage.json. This skill targets v5 (React 18+,useShallowfromzustand/react/shallow, no default export). - Find existing stores (
grep -rln "from 'zustand'\|from \"zustand\"" src) and follow their file layout and naming. - Decide whether the data belongs in Zustand at all:
| Data | Home |
|---|---|
| Server data (lists, records, user profile from API) | TanStack Query, RTK Query or route loaders |
| URL-shareable state (filters, tab, page) | search params |
| Form drafts for one form | form library or local state |
| State used by one component subtree | useState / useReducer / context |
| Cross-cutting client state (sidebar, cart before checkout, editor selection, wizard progress) | Zustand |
Store template
// src/stores/cart-store.ts
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
type CartItem = { sku: string; qty: number };
type CartState = {
items: CartItem[];
couponCode: string | null;
actions: {
add: (sku: string) => void;
setQty: (sku: string, qty: number) => void;
clear: () => void;
};
};
export const useCartStore = create<CartState>()(
devtools(
persist(
(set) => ({
items: [],
couponCode: null,
actions: {
add: (sku) =>
set((s) => {
const existing = s.items.find((i) => i.sku === sku);
return {
items: existing
? s.items.map((i) => (i.sku === sku ? { ...i, qty: i.qty + 1 } : i))
: [...s.items, { sku, qty: 1 }],
};
}, undefined, "cart/add"),
setQty: (sku, qty) =>
set((s) => ({ items: s.items.map((i) => (i.sku === sku ? { ...i, qty } : i)) }), undefined, "cart/setQty"),
clear: () => set({ items: [], couponCode: null }, undefined, "cart/clear"),
},
}),
{
name: "cart-v1",
version: 1,
storage: createJSONStorage(() => localStorage),
partialize: (s) => ({ items: s.items, couponCode: s.couponCode }), // never persist actions
migrate: (persisted, version) => {
if (version === 0) {
// v0 stored { lines: [...] }
const old = persisted as { lines?: CartItem[] };
return { items: old.lines ?? [], couponCode: null };
}
return persisted as Pick<CartState, "items" | "couponCode">;
},
},
),
{ name: "cart" },
),
);
// Export hooks, not the store, so components cannot subscribe to everything.
export const useCartItems = () => useCartStore((s) => s.items);
export const useCartCount = () => useCartStore((s) => s.items.reduce((n, i) => n + i.qty, 0));
export const useCartActions = () => useCartStore((s) => s.actions);
- Use the curried
create<T>()(...)form in TypeScript so middleware types infer. - Middleware order:
devtoolsoutermost, thenpersist, thenimmerinnermost if used. - Grouping actions under one stable
actionsobject lets components select all of them without re-rendering, because the object identity never changes. - The third argument to
setunderdevtoolsis the action name shown in Redux DevTools; name actions so traces are readable.
Selectors
| Pattern | Re-renders when | Use |
|---|---|---|
useStore((s) => s.count) | count changes | default: one primitive per hook |
useStore(useShallow((s) => ({ a: s.a, b: s.b }))) | a or b changes | several values at once |
useStore(useShallow((s) => s.items.map((i) => i.sku))) | the mapped array’s elements change | derived arrays |
useStore((s) => s) or useStore() | anything changes | never in components |
A selector that returns a new object or array on every call without useShallow
causes “Maximum update depth exceeded” in v5 (v4 only re-rendered too much). Fix it with
useShallow or by selecting primitives.
Expensive derived values: compute in the selector only if cheap; otherwise derive in the
component with useMemo from narrowly selected inputs.
Slices for larger stores
import type { StateCreator } from "zustand";
type UiSlice = { sidebarOpen: boolean; toggleSidebar: () => void };
type EditorSlice = { selection: string[]; select: (ids: string[]) => void };
type Store = UiSlice & EditorSlice;
const createUiSlice: StateCreator<Store, [], [], UiSlice> = (set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});
const createEditorSlice: StateCreator<Store, [], [], EditorSlice> = (set) => ({
selection: [],
select: (ids) => set({ selection: ids }),
});
export const useAppStore = create<Store>()((...a) => ({ ...createUiSlice(...a), ...createEditorSlice(...a) }));
Apply middleware once on the combined store, not inside individual slices. Prefer several small independent stores over one large sliced store when the slices never read each other.
Server rendering (Next.js App Router and similar)
A module-level create() store is a singleton per server process: one user’s state can
leak into another’s render. For state that is initialized per request or per user:
// src/stores/editor-store-provider.tsx
"use client";
import { createContext, useContext, useState } from "react";
import { createStore, useStore } from "zustand";
type EditorState = { docId: string; dirty: boolean; markDirty: () => void };
const createEditorStore = (docId: string) =>
createStore<EditorState>()((set) => ({ docId, dirty: false, markDirty: () => set({ dirty: true }) }));
const EditorStoreContext = createContext<ReturnType<typeof createEditorStore> | null>(null);
export function EditorStoreProvider({ docId, children }: { docId: string; children: React.ReactNode }) {
const [store] = useState(() => createEditorStore(docId));
return <EditorStoreContext.Provider value={store}>{children}</EditorStoreContext.Provider>;
}
export function useEditorStore<T>(selector: (s: EditorState) => T) {
const store = useContext(EditorStoreContext);
if (!store) throw new Error("useEditorStore must be used inside EditorStoreProvider");
return useStore(store, selector);
}
Persisted stores and SSR: the server renders defaults, the client rehydrates from
storage, and the markup differs. Use skipHydration: true and call
useCartStore.persist.rehydrate() in an effect, or render storage-dependent UI only
after useCartStore.persist.hasHydrated() is true.
Testing
- Reset state between tests: capture
const initial = useCartStore.getState()once and calluseCartStore.setState(initial, true)inbeforeEach(thetruereplaces instead of merging). - Test actions through
useCartStore.getState().actions.add("A")without rendering. - For component tests, render with real stores; mock only the network layer.
Gotchas
- Calling
setwith a partial object merges one level deep only. Nested objects must be spread manually or updated with theimmermiddleware. - Mutating state in place (
s.items.push(x); return s) keeps the same reference, so selectors see no change and nothing re-renders. - Persisting functions or class instances fails silently;
partializeto plain data. - Changing the persisted shape without bumping
versionand addingmigratebreaks returning users’ state. - Subscribing outside React (
useCartStore.subscribe) needs the returned unsubscribe called on cleanup.
Verification
- Type-check passes with no
anyin store types. - React DevTools “Highlight updates”: changing one store field re-renders only its subscribers.
- Redux DevTools shows named actions when
devtoolsis used. - Reload with persisted state from the previous version present; migration runs and the UI renders without hydration warnings.
- Tests reset the store and pass in random order.
Output
Document each store: purpose, fields, actions, exported selector hooks, persistence key and version, and whether it is a global singleton or per-request provider.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Zustand State"
short_description: "Small stores, narrow selectors, versioned persistence"
default_prompt: "$zustand-state Add a cart store with persistence that survives a schema change" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.