| name | state-management |
| description | Frontend state management patterns for React apps. Use when implementing Redux Toolkit, Zustand, Jotai, React Query, or any global state management solution. |
State Management Patterns
Zustand (Recommended — simple & powerful)
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
email: string
name: string
}
interface AuthStore {
user: User | null
token: string | null
isLoading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
setUser: (user: User) => void
}
export const useAuthStore = create<AuthStore>()(
persist(
(set) => ({
user: null,
token: null,
isLoading: false,
login: async (email, password) => {
set({ isLoading: true })
try {
const { user, token } = await api.login(email, password)
set({ user, token, isLoading: false })
} catch (error) {
set({ isLoading: false })
throw error
}
},
logout: () => set({ user: null, token: null }),
setUser: (user) => set({ user }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ token: state.token }),
}
)
)
const { user, login, logout } = useAuthStore()
const isLoggedIn = useAuthStore((state) => state.user !== null)
React Query / TanStack Query (Server state)
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
export function useUsers(filters?: UserFilters) {
return useQuery({
queryKey: ['users', filters],
queryFn: () => api.getUsers(filters),
staleTime: 30_000,
})
}
export function useUser(id: string) {
return useQuery({
queryKey: ['users', id],
queryFn: () => api.getUser(id),
enabled: !!id,
})
}
export function useUpdateUser() {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateUserInput) => api.updateUser(data),
onSuccess: (updatedUser) => {
qc.setQueryData(['users', updatedUser.id], updatedUser)
qc.invalidateQueries({ queryKey: ['users'] })
},
})
}
export function useInfiniteUsers() {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: ({ pageParam = 1 }) => api.getUsers({ page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
})
}
function UserList() {
const { data, isLoading, error } = useUsers()
const { mutate: updateUser, isPending } = useUpdateUser()
if (isLoading) return <Skeleton />
if (error) return <ErrorMessage error={error} />
return data?.map(user => (
<UserCard
key={user.id}
user={user}
onUpdate={(data) => updateUser({ id: user.id, ...data })}
/>
))
}
Redux Toolkit (Complex state)
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
interface Post { id: string; title: string; body: string }
interface PostsState {
items: Post[]
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
export const fetchPosts = createAsyncThunk('posts/fetchAll', async () => {
return await api.getPosts()
})
export const postsSlice = createSlice({
name: 'posts',
initialState: { items: [], status: 'idle', error: null } as PostsState,
reducers: {
addPost: (state, action: PayloadAction<Post>) => {
state.items.push(action.payload)
},
removePost: (state, action: PayloadAction<string>) => {
state.items = state.items.filter(p => p.id !== action.payload)
},
},
extraReducers: (builder) => {
builder
.addCase(fetchPosts.pending, (state) => { state.status = 'loading' })
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message ?? null
})
},
})
import { configureStore } from '@reduxjs/toolkit'
import { useSelector, useDispatch } from 'react-redux'
export const store = configureStore({
reducer: { posts: postsSlice.reducer },
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
Jotai (Atomic state)
import { atom, useAtom, useAtomValue } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
export const userAtom = atomWithStorage<User | null>('user', null)
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')
export const isAdminAtom = atom((get) => get(userAtom)?.role === 'ADMIN')
export const logoutAtom = atom(null, (get, set) => {
set(userAtom, null)
set(themeAtom, 'light')
})
function Header() {
const user = useAtomValue(userAtom)
const isAdmin = useAtomValue(isAdminAtom)
const [, logout] = useAtom(logoutAtom)
return <nav>{user ? <button onClick={logout}>Logout</button> : null}</nav>
}
Context + useReducer (Built-in, no deps)
type CartAction =
| { type: 'ADD_ITEM'; item: CartItem }
| { type: 'REMOVE_ITEM'; id: string }
| { type: 'CLEAR_CART' }
function cartReducer(state: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case 'ADD_ITEM':
const existing = state.find(i => i.id === action.item.id)
if (existing) return state.map(i => i.id === action.item.id
? { ...i, quantity: i.quantity + 1 } : i)
return [...state, { ...action.item, quantity: 1 }]
case 'REMOVE_ITEM': return state.filter(i => i.id !== action.id)
case 'CLEAR_CART': return []
default: return state
}
}
const CartContext = createContext<{ cart: CartItem[]; dispatch: Dispatch<CartAction> } | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [cart, dispatch] = useReducer(cartReducer, [])
return <CartContext value={{ cart, dispatch }}>{children}</CartContext>
}
export const useCart = () => {
const ctx = use(CartContext)
if (!ctx) throw new Error('useCart must be used within CartProvider')
return ctx
}
Rules
- Server state (API data): React Query / TanStack Query
- Global client state (auth, settings): Zustand or Jotai
- Complex local state (shopping cart, multi-step form): useReducer + Context
- Redux only for very large apps with complex cross-slice interactions
- Never store server data in Redux/Zustand — that's React Query's job
- Zustand: use selectors (
useStore(state => state.user)) not full store (prevents re-renders)
- React Query: set
staleTime appropriately — default is 0 (always refetch on mount)
- Always invalidate queries after mutations — never manually merge response into cache