Client-Side State Management
Decision: Library Selection
| Need |
Use |
Why |
| Simple shared state, <5 consumers |
Context API |
Zero dependencies, built-in |
| Medium complexity, performance matters |
Zustand |
1.5kb, no boilerplate, auto re-render optimization |
| Large app, strict patterns needed |
Redux Toolkit |
DevTools, middleware ecosystem, time-travel |
| Fine-grained reactivity, atoms |
Jotai |
Bottom-up, minimal re-renders, composable |
| Server state (fetching/caching) |
React Query or SWR |
Deduplication, background refresh, cache |
Decision: Global vs Local State
Keep Local (useState/useReducer):
- Form input values before submission
- UI state (open/closed, hover, focus)
- Component-specific loading/error states
Promote to Global:
- User session/auth
- Theme/locale preferences
- Data shared across 3+ unrelated components
- State that must survive navigation
State Normalization
Flatten nested data to avoid update complexity:
// Bad: nested
{ posts: [{ id: 1, author: { id: 1, name: 'Jo' }, comments: [...] }] }
// Good: normalized
{
posts: { byId: { 1: { id: 1, authorId: 1, commentIds: [1,2] } }, allIds: [1] },
users: { byId: { 1: { id: 1, name: 'Jo' } } },
comments: { byId: { 1: {...}, 2: {...} } }
}
Optimistic Updates Pattern
Update UI immediately, rollback on error:
// React Query
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], old => [...old, newTodo])
return { previous }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] })
})
State Persistence
// Zustand with persist middleware
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({ theme: 'light', setTheme: (t) => set({ theme: t }) }),
{
name: 'app-settings',
storage: createJSONStorage(() => localStorage), // or sessionStorage
partialize: (state) => ({ theme: state.theme }) // persist only specific keys
}
)
)
Performance: Selector Patterns
Prevent unnecessary re-renders by selecting only needed state:
// Zustand - component only re-renders when `count` changes
const count = useStore((state) => state.count)
// Jotai - selectAtom for derived slices
const nameAtom = selectAtom(userAtom, (user) => user.name)
// React Query - select option
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
select: (data) => data.name // component only gets name
})
Reference Files
- zustand-patterns.md: Store setup, middleware composition, TypeScript patterns
- server-state.md: React Query/SWR configuration, caching strategies, mutation patterns
- context-patterns.md: Context setup, optimization techniques
Performance Checklist
1---2name: client-state-management3description: Guide for implementing client-side state management in React applications. Use when building state architecture, selecting state libraries (Context, Zustand, Redux, Jotai), implementing caching strategies (React Query, SWR), optimistic updates, state persistence, or optimizing re-renders. Triggers on questions about global vs local state, state normalization, or selector patterns.4---5
6# Client-Side State Management
7
8## Decision: Library Selection
9
10| Need | Use | Why |
11|------|-----|-----|
12| Simple shared state, <5 consumers | **Context API** | Zero dependencies, built-in |
13| Medium complexity, performance matters | **Zustand** | 1.5kb, no boilerplate, auto re-render optimization |
14| Large app, strict patterns needed | **Redux Toolkit** | DevTools, middleware ecosystem, time-travel |
15| Fine-grained reactivity, atoms | **Jotai** | Bottom-up, minimal re-renders, composable |
16| Server state (fetching/caching) | **React Query** or **SWR** | Deduplication, background refresh, cache |
17
18## Decision: Global vs Local State
19
20**Keep Local** (useState/useReducer):
21- Form input values before submission
22- UI state (open/closed, hover, focus)
23- Component-specific loading/error states
24
25**Promote to Global**:
26- User session/auth
27- Theme/locale preferences
28- Data shared across 3+ unrelated components
29- State that must survive navigation
30
31## State Normalization
32
33Flatten nested data to avoid update complexity:
34
35```typescript
36// Bad: nested
37{ posts: [{ id: 1, author: { id: 1, name: 'Jo' }, comments: [...] }] }
38
39// Good: normalized
40{
41 posts: { byId: { 1: { id: 1, authorId: 1, commentIds: [1,2] } }, allIds: [1] },
42 users: { byId: { 1: { id: 1, name: 'Jo' } } },
43 comments: { byId: { 1: {...}, 2: {...} } }
44}
45```
46
47## Optimistic Updates Pattern
48
49Update UI immediately, rollback on error:
50
51```typescript
52// React Query
53useMutation({
54 mutationFn: updateTodo,
55 onMutate: async (newTodo) => {
56 await queryClient.cancelQueries({ queryKey: ['todos'] })
57 const previous = queryClient.getQueryData(['todos'])
58 queryClient.setQueryData(['todos'], old => [...old, newTodo])
59 return { previous }
60 },
61 onError: (err, newTodo, context) => {
62 queryClient.setQueryData(['todos'], context.previous)
63 },
64 onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] })
65})
66```
67
68## State Persistence
69
70```typescript
71// Zustand with persist middleware
72import { create } from 'zustand'
73import { persist, createJSONStorage } from 'zustand/middleware'
74
75const useStore = create(
76 persist(
77 (set) => ({ theme: 'light', setTheme: (t) => set({ theme: t }) }),
78 {
79 name: 'app-settings',
80 storage: createJSONStorage(() => localStorage), // or sessionStorage
81 partialize: (state) => ({ theme: state.theme }) // persist only specific keys
82 }
83 )
84)
85```
86
87## Performance: Selector Patterns
88
89Prevent unnecessary re-renders by selecting only needed state:
90
91```typescript
92// Zustand - component only re-renders when `count` changes
93const count = useStore((state) => state.count)
94
95// Jotai - selectAtom for derived slices
96const nameAtom = selectAtom(userAtom, (user) => user.name)
97
98// React Query - select option
99useQuery({
100 queryKey: ['user'],
101 queryFn: fetchUser,
102 select: (data) => data.name // component only gets name
103})
104```
105
106## Reference Files
107
108- **[zustand-patterns.md](references/zustand-patterns.md)**: Store setup, middleware composition, TypeScript patterns
109- **[server-state.md](references/server-state.md)**: React Query/SWR configuration, caching strategies, mutation patterns
110- **[context-patterns.md](references/context-patterns.md)**: Context setup, optimization techniques
111
112## Performance Checklist
113
114- [ ] Selectors return minimal data needed
115- [ ] Memoize selectors with expensive computations
116- [ ] Split stores by domain (don't put everything in one store)
117- [ ] Use `shallow` comparison for object selections in Zustand
118- [ ] Set appropriate `staleTime`/`cacheTime` for server state
119- [ ] Avoid storing derived state (compute from source)