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)
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)
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)
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
<button => setCount(0)}>Reset</button>
</div>
)
}
React Hook Form
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 (data: FormValues) => {
await createUser(data)
}
return (
<form
<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
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 })
// ...
}