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.

  • Frontend
  • $zustand-state
  • 210 lines
  • 2 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- zustand-state

Installs to ~/.agents/skills/zustand-state/ for every project on this machine.

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

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.

~/.agents/skills/zustand-state/SKILL.md

--- name: zustand-state description: Designs Zustand v5 stores for React client state — typed create with the curried form, slices, atomic selectors and useShallow, actions kept in the… license: MIT ---

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

  1. Read AGENTS.md and check zustand in package.json. This skill targets v5 (React 18+, useShallow from zustand/react/shallow, no default export).
  2. Find existing stores (grep -rln "from 'zustand'\|from \"zustand\"" src) and follow their file layout and naming.
  3. Decide whether the data belongs in Zustand at all:
DataHome
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 formform library or local state
State used by one component subtreeuseState / 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: devtools outermost, then persist, then immer innermost if used.
  • Grouping actions under one stable actions object lets components select all of them without re-rendering, because the object identity never changes.
  • The third argument to set under devtools is the action name shown in Redux DevTools; name actions so traces are readable.

Selectors

PatternRe-renders whenUse
useStore((s) => s.count)count changesdefault: one primitive per hook
useStore(useShallow((s) => ({ a: s.a, b: s.b })))a or b changesseveral values at once
useStore(useShallow((s) => s.items.map((i) => i.sku)))the mapped array’s elements changederived arrays
useStore((s) => s) or useStore()anything changesnever 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 call useCartStore.setState(initial, true) in beforeEach (the true replaces 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 set with a partial object merges one level deep only. Nested objects must be spread manually or updated with the immer middleware.
  • 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; partialize to plain data.
  • Changing the persisted shape without bumping version and adding migrate breaks returning users’ state.
  • Subscribing outside React (useCartStore.subscribe) needs the returned unsubscribe called on cleanup.

Verification

  • Type-check passes with no any in store types.
  • React DevTools “Highlight updates”: changing one store field re-renders only its subscribers.
  • Redux DevTools shows named actions when devtools is 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem