home / skills / masanao-ohba / claude-manifests / patterns

This skill helps you implement and pattern Zustand stores with slices, derived values, selectors, and middleware for predictable client-side state.

npx playbooks add skill masanao-ohba/claude-manifests --skill patterns

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
10.6 KB
---
name: zustand-patterns
description: Zustand patterns for predictable client-side state management
---

# Zustand State Management Patterns

## Basic Store

### Simple Store

```tsx
// stores/counter.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));
```

### Usage

```tsx
'use client';

import { useCounterStore } from '@/stores/counter';

export function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}
```

## Store Patterns

### Slice Pattern

Split large stores into focused slices:

```tsx
// stores/app-store.ts
import { create } from 'zustand';

// Auth slice
interface AuthSlice {
  user: User | null;
  isAuthenticated: boolean;
  login: (user: User) => void;
  logout: () => void;
}

const createAuthSlice = (set): AuthSlice => ({
  user: null,
  isAuthenticated: false,
  login: (user) => set({ user, isAuthenticated: true }),
  logout: () => set({ user: null, isAuthenticated: false }),
});

// UI slice
interface UISlice {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
}

const createUISlice = (set): UISlice => ({
  sidebarOpen: true,
  toggleSidebar: () =>
    set((state) => ({ sidebarOpen: !state.sidebarOpen })),
});

// Combined store
type AppState = AuthSlice & UISlice;

export const useAppStore = create<AppState>()((...a) => ({
  ...createAuthSlice(...a),
  ...createUISlice(...a),
}));
```

### Computed Values

Derive values from state:

```tsx
interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  // Computed value as function
  total: () => number;
  itemCount: () => number;
}

export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  total: () => {
    return get().items.reduce((sum, item) => sum + item.price, 0);
  },
  itemCount: () => get().items.length,
}));

// Usage
function CartSummary() {
  const total = useCartStore((state) => state.total());
  const itemCount = useCartStore((state) => state.itemCount());

  return <div>Total: ${total} ({itemCount} items)</div>;
}
```

## Selectors

### Optimized Selectors

Prevent unnecessary re-renders:

```tsx
import { useCartStore } from '@/stores/cart';
import { shallow } from 'zustand/shallow';

// Bad: Entire store subscribed
function BadComponent() {
  const store = useCartStore();
  return <div>{store.items.length}</div>;
}

// Good: Only subscribe to needed value
function GoodComponent() {
  const itemCount = useCartStore((state) => state.items.length);
  return <div>{itemCount}</div>;
}

// Better: Multiple values with shallow comparison
function BetterComponent() {
  const { items, addItem } = useCartStore(
    (state) => ({ items: state.items, addItem: state.addItem }),
    shallow
  );

  return <div>{items.length}</div>;
}
```

### Custom Selectors

```tsx
// stores/selectors.ts
import { useUserStore } from './user';

export const useIsAdmin = () =>
  useUserStore((state) => state.user?.role === 'admin');

export const useUserName = () =>
  useUserStore((state) => state.user?.name ?? 'Guest');

export const useHasPermission = (permission: string) =>
  useUserStore((state) =>
    state.user?.permissions.includes(permission)
  );

// Usage
function AdminPanel() {
  const isAdmin = useIsAdmin();
  if (!isAdmin) return null;
  return <div>Admin Panel</div>;
}
```

## Async Actions

### Fetch Data

```tsx
interface PostsState {
  posts: Post[];
  isLoading: boolean;
  error: string | null;
  fetchPosts: () => Promise<void>;
}

export const usePostsStore = create<PostsState>((set) => ({
  posts: [],
  isLoading: false,
  error: null,
  fetchPosts: async () => {
    set({ isLoading: true, error: null });
    try {
      const response = await fetch('/api/posts');
      const posts = await response.json();
      set({ posts, isLoading: false });
    } catch (error) {
      set({ error: error.message, isLoading: false });
    }
  },
}));

// Usage
function PostList() {
  const { posts, isLoading, error, fetchPosts } = usePostsStore();

  useEffect(() => {
    fetchPosts();
  }, [fetchPosts]);

  if (isLoading) return <Loading />;
  if (error) return <Error message={error} />;
  return <div>{posts.map((post) => <PostCard post={post} />)}</div>;
}
```

## Middleware

### Persist

Persist state to localStorage:

```tsx
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface UserPreferences {
  theme: 'light' | 'dark';
  language: string;
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (language: string) => void;
}

export const usePreferencesStore = create<UserPreferences>()(
  persist(
    (set) => ({
      theme: 'light',
      language: 'en',
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
    }),
    {
      name: 'user-preferences', // localStorage key
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
      }), // Only persist these fields
    }
  )
);
```

### Devtools

Redux DevTools integration:

```tsx
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useAppStore = create<AppState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),
      decrement: () => set((state) => ({ count: state.count - 1 }), false, 'decrement'),
    }),
    { name: 'AppStore' }
  )
);
```

### Immer

Use Immer for immutable updates:

```tsx
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
}

export const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    addTodo: (text) =>
      set((state) => {
        state.todos.push({ id: Date.now().toString(), text, done: false });
      }),
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done;
      }),
  }))
);
```

### Combined Middleware

```tsx
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

export const useStore = create<State>()(
  devtools(
    persist(
      immer((set) => ({
        // store implementation
      })),
      { name: 'app-storage' }
    ),
    { name: 'AppStore' }
  )
);
```

## Store Organization

### Structure

```
stores/
├── index.ts              # Export all stores
├── auth-store.ts         # Authentication state
├── cart-store.ts         # Shopping cart
├── ui-store.ts           # UI state (modals, sidebar, etc.)
├── preferences-store.ts  # User preferences
└── selectors/
    ├── auth-selectors.ts
    └── cart-selectors.ts
```

### Index File

```tsx
// stores/index.ts
export { useAuthStore } from './auth-store';
export { useCartStore } from './cart-store';
export { useUIStore } from './ui-store';
export { usePreferencesStore } from './preferences-store';
```

## TypeScript Patterns

### Typed Actions

```tsx
interface UserState {
  user: User | null;
  status: 'idle' | 'loading' | 'success' | 'error';
  error: string | null;
  setUser: (user: User) => void;
  clearUser: () => void;
  fetchUser: (id: string) => Promise<void>;
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  status: 'idle',
  error: null,
  setUser: (user) => set({ user, status: 'success', error: null }),
  clearUser: () => set({ user: null, status: 'idle', error: null }),
  fetchUser: async (id) => {
    set({ status: 'loading' });
    try {
      const user = await api.fetchUser(id);
      set({ user, status: 'success', error: null });
    } catch (error) {
      set({ status: 'error', error: error.message });
    }
  },
}));
```

## Testing

### Test Setup

```tsx
// __tests__/stores/counter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounterStore } from '@/stores/counter';

describe('useCounterStore', () => {
  beforeEach(() => {
    // Reset store before each test
    useCounterStore.setState({ count: 0 });
  });

  it('increments count', () => {
    const { result } = renderHook(() => useCounterStore());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('decrements count', () => {
    const { result } = renderHook(() => useCounterStore());

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(-1);
  });
});
```

## Best Practices

### Do

- Keep stores focused on specific domains
- Use selectors to prevent unnecessary re-renders
- Use middleware for cross-cutting concerns
- Type all stores with TypeScript
- Extract complex logic to separate functions
- Use shallow comparison for object selections
- Persist only necessary state

### Don't

- Don't put all state in one store
- Don't select entire store when only part is needed
- Don't mutate state directly (use set or Immer)
- Don't use Zustand for server state (use React Query)
- Don't persist sensitive data
- Don't forget to reset state when needed

## When to Use

### Use Zustand

- Client-side UI state (modals, sidebar, theme)
- Form state (multi-step forms)
- Global app state (user preferences, settings)
- Temporary state shared across components

### Use React Query

- Server state (API data)
- Caching and revalidation
- Background updates
- Optimistic updates with server sync

### Use React State

- Local component state
- Form inputs
- Toggle states
- State not shared with other components

## Performance

### Optimization

- Use selective subscriptions (don't select entire store)
- Use shallow comparison for object selections
- Batch updates when possible
- Split large stores into smaller, focused stores
- Use computed values (functions) instead of derived state

### Monitoring

- Use Redux DevTools middleware in development
- Monitor render counts in React DevTools
- Profile component re-renders
- Check localStorage size if using persist

Overview

This skill provides a concise collection of Zustand state management patterns for predictable client-side state. It documents common store shapes, middleware setups, selectors, async actions, TypeScript typings, and testing tips to build maintainable stores. The content is practical and focused on reducing re-renders, organizing stores, and combining middleware safely.

How this skill works

The skill presents ready-to-adopt patterns: simple stores, slice composition, computed values via getters, optimized selectors, and common middleware (persist, devtools, immer). It explains how to structure stores and selectors, wire async actions, persist selective state to localStorage, and combine devtools/persist/immer in one store. Examples show usage and testing strategies for predictable outcomes.

When to use it

  • Manage client-side UI and global app state like modals, sidebar, theme, and preferences.
  • Share temporary or form state across multiple components (multi-step forms, wizards).
  • Create small focused stores rather than one monolithic store for performance.
  • Add persistence for non-sensitive preferences and integrate DevTools during development.
  • Use computed selectors to avoid deriving expensive values in components.

Best practices

  • Split large stores into domain-focused slices and combine them for clarity and testability.
  • Subscribe only to needed values or use shallow when selecting multiple fields to prevent re-renders.
  • Type stores and actions with TypeScript to catch errors early and document behavior.
  • Persist only non-sensitive fields and partialize persisted state to limit localStorage size.
  • Use immer for safe immutable updates and devtools for debugging, but keep middleware order consistent.

Example use cases

  • A preferences store persisted to localStorage to remember theme and language across sessions.
  • A cart store with computed total() and itemCount() functions to avoid storing derived state.
  • An auth slice combined with UI slice to keep authentication and layout state separated but accessible.
  • Async posts store that fetches data, exposes isLoading/error, and can be invoked from components with useEffect.
  • Testing a counter or todo store with renderHook and resetting state between tests.

FAQ

When should I use computed functions vs derived state values?

Use computed functions (getters) when the value is cheap to compute on-demand or depends on current state; avoid storing derived values to prevent stale data and unnecessary updates.

How do I prevent unnecessary re-renders with Zustand?

Subscribe to specific fields using selectors, use shallow when selecting multiple fields, and avoid returning the whole store object from hooks.