# React State Management

> When to activate: Zustand, Jotai, TanStack Query, React Query, Redux Toolkit, global state, client state, server state

- Skill: `mattakushi432/react-state-management` (Agent Skill)
- Install (CLI): `npx skillmds@latest add mattakushi432/react-state-management`
- Raw SKILL.md: https://api.skillmd.com/api/skills/mattakushi432/react-state-management/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Mattakushi432 (https://skillmd.com/u/mattakushi432)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/mattakushi432/react-state-management

---


# React State Management

## When to Use What

| Concern | Tool |
|---------|------|
| Server/async state | TanStack Query |
| Global UI state | Zustand |
| Atomic state | Jotai |
| Form state | React Hook Form |
| Complex reducers | useReducer / Redux Toolkit |
| URL state | search params |

## TanStack Query (server state)
```tsx
import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Provider
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
})

// Queries
function useUser(id: string) {
  return useQuery({
    queryKey: ['users', id],
    queryFn: () => api.users.getById(id),
    enabled: !!id,
  })
}

function useUsers(filter: UsersFilter) {
  return useQuery({
    queryKey: ['users', 'list', filter],
    queryFn: () => api.users.list(filter),
    placeholderData: keepPreviousData,  // smooth pagination
  })
}

// Mutations with cache invalidation
function useCreateUser() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (data: CreateUserInput) => api.users.create(data),
    onSuccess: (newUser) => {
      qc.invalidateQueries({ queryKey: ['users', 'list'] })
      qc.setQueryData(['users', newUser.id], newUser)
    },
  })
}

// Optimistic update
function useUpdateUser() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateUserInput }) =>
      api.users.update(id, data),
    onMutate: async ({ id, data }) => {
      await qc.cancelQueries({ queryKey: ['users', id] })
      const snapshot = qc.getQueryData<User>(['users', id])
      qc.setQueryData(['users', id], (old: User) => ({ ...old, ...data }))
      return { snapshot }
    },
    onError: (_err, { id }, ctx) => {
      qc.setQueryData(['users', id], ctx?.snapshot)
    },
    onSettled: (_data, _err, { id }) => {
      qc.invalidateQueries({ queryKey: ['users', id] })
    },
  })
}
```

## Zustand (client UI state)
```ts
import { create } from 'zustand'
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

interface NotificationStore {
  notifications: Notification[]
  add: (n: Omit<Notification, 'id'>) => void
  dismiss: (id: string) => void
  dismissAll: () => void
}

export const useNotifications = create<NotificationStore>()(
  devtools(
    immer((set) => ({
      notifications: [],

      add: (n) => set(state => {
        state.notifications.push({ ...n, id: crypto.randomUUID() })
      }),

      dismiss: (id) => set(state => {
        state.notifications = state.notifications.filter(n => n.id !== id)
      }),

      dismissAll: () => set(state => { state.notifications = [] }),
    })),
    { name: 'notifications' }
  )
)

// Slice pattern for large stores
interface UserPrefsSlice {
  theme: 'light' | 'dark'
  setTheme: (theme: 'light' | 'dark') => void
}

const createUserPrefsSlice = (set: any): UserPrefsSlice => ({
  theme: 'light',
  setTheme: (theme) => set({ theme }),
})
```

## Jotai (atomic state)
```ts
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { atomWithStorage, atomWithReset } from 'jotai/utils'

// Primitive atoms
const countAtom = atom(0)
const nameAtom  = atom('')

// Derived atoms (read-only)
const doubleAtom = atom(get => get(countAtom) * 2)

// Write atom
const incrementAtom = atom(null, (get, set) => {
  set(countAtom, get(countAtom) + 1)
})

// Persistent atom (localStorage)
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')

// Usage
function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const double = useAtomValue(doubleAtom)
  const increment = useSetAtom(incrementAtom)

  return (
    <div>
      <p>{count} × 2 = {double}</p>
      <button onClick={increment}>+1</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  )
}
```

## React Hook Form
```tsx
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  email:    z.string().email(),
  password: z.string().min(8),
  role:     z.enum(['user', 'admin']),
})

type FormValues = z.infer<typeof schema>

function SignupForm() {
  const { register, handleSubmit, control, formState: { errors, isSubmitting } } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: { role: 'user' },
  })

  const onSubmit = async (data: FormValues) => {
    await createUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} aria-invalid={!!errors.email} />
      {errors.email && <span role="alert">{errors.email.message}</span>}

      <Controller
        name="role"
        control={control}
        render={({ field }) => (
          <select {...field}>
            <option value="user">User</option>
            <option value="admin">Admin</option>
          </select>
        )}
      />

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Signing up...' : 'Sign up'}
      </button>
    </form>
  )
}
```

## URL State
```ts
import { useSearchParams } from 'react-router-dom'

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams()
  const page  = Number(searchParams.get('page') ?? '1')
  const sort  = searchParams.get('sort') ?? 'newest'
  const query = searchParams.get('q') ?? ''

  const setPage = (p: number) =>
    setSearchParams(prev => { prev.set('page', String(p)); return prev })

  const setSort = (s: string) =>
    setSearchParams(prev => { prev.set('sort', s); prev.set('page', '1'); return prev })

  // ...
}
```

