react-state-management
Master modern React state management with Redux Toolkit, Zustand, Jotai, and React Query. Use when setting up global state, managing server state, or choosing between state management solutions.
- risk
- unknown
- source
- community
- date added
- 2026-02-27
React State Management
Comprehensive guide to modern React state management patterns, from local component state to global stores and server state synchronization.
Do not use this skill when
- The task is unrelated to react state management
- You need a different domain or tool outside this scope
Instructions
- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
- If detailed examples are required, open
resources/implementation-playbook.md.
Use this skill when
- Setting up global state management in a React app
- Choosing between Redux Toolkit, Zustand, or Jotai
- Managing server state with React Query or SWR
- Implementing optimistic updates
- Debugging state-related issues
- Migrating from legacy Redux to modern patterns
Core Concepts
1. State Categories
| Type | Description | Solutions |
|---|---|---|
| Local State | Component-specific, UI state | useState, useReducer |
| Global State | Shared across components | Redux Toolkit, Zustand, Jotai |
| Server State | Remote data, caching | React Query, SWR, RTK Query |
| URL State | Route parameters, search | React Router, nuqs |
| Form State | Input values, validation | React Hook Form, Formik |
2. Selection Criteria
Small app, simple state → Zustand or Jotai Large app, complex state → Redux Toolkit Heavy server interaction → React Query + light client state Atomic/granular updates → Jotai
Quick Start
Zustand (Simplest)
// store/useStore.ts import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' interface AppState { user: User | null theme: 'light' | 'dark' setUser: (user: User | null) => void toggleTheme: () => void } export const useStore = create<AppState>()( devtools( persist( (set) => ({ user: null, theme: 'light', setUser: (user) => set({ user }), toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })), }), { name: 'app-storage' } ) ) ) // Usage in component function Header() { const { user, theme, toggleTheme } = useStore() return ( <header className={theme}> {user?.name} <button onClick={toggleTheme}>Toggle Theme</button> </header> ) }
Patterns
Pattern 1: Redux Toolkit with TypeScript
// store/index.ts import { configureStore } from '@reduxjs/toolkit' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import userReducer from './slices/userSlice' import cartReducer from './slices/cartSlice' export const store = configureStore({ reducer: { user: userReducer, cart: cartReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: ['persist/PERSIST'], }, }), }) export type RootState = ReturnType<typeof store.getState> export type AppDispatch = typeof store.dispatch // Typed hooks export const useAppDispatch: () => AppDispatch = useDispatch export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// store/slices/userSlice.ts import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' interface User { id: string email: string name: string } interface UserState { current: User | null status: 'idle' | 'loading' | 'succeeded' | 'failed' error: string | null } const initialState: UserState = { current: null, status: 'idle', error: null, } export const fetchUser = createAsyncThunk( 'user/fetchUser', async (userId: string, { rejectWithValue }) => { try { const response = await fetch(`/api/users/${userId}`) if (!response.ok) throw new Error('Failed to fetch user') return await response.json() } catch (error) { return rejectWithValue((error as Error).message) } } ) const userSlice = createSlice({ name: 'user', initialState, reducers: { setUser: (state, action: PayloadAction<User>) => { state.current = action.payload state.status = 'succeeded' }, clearUser: (state) => { state.current = null state.status = 'idle' }, }, extraReducers: (builder) => { builder .addCase(fetchUser.pending, (state) => { state.status = 'loading' state.error = null }) .addCase(fetchUser.fulfilled, (state, action) => { state.status = 'succeeded' state.current = action.payload }) .addCase(fetchUser.rejected, (state, action) => { state.status = 'failed' state.error = action.payload as string }) }, }) export const { setUser, clearUser } = userSlice.actions export default userSlice.reducer
Pattern 2: Zustand with Slices (Scalable)
// store/slices/createUserSlice.ts import { StateCreator } from 'zustand' export interface UserSlice { user: User | null isAuthenticated: boolean login: (credentials: Credentials) => Promise<void> logout: () => void } export const createUserSlice: StateCreator< UserSlice & CartSlice, // Combined store type [], [], UserSlice > = (set, get) => ({ user: null, isAuthenticated: false, login: async (credentials) => { const user = await authApi.login(credentials) set({ user, isAuthenticated: true }) }, logout: () => { set({ user: null, isAuthenticated: false }) // Can access other slices // get().clearCart() }, }) // store/index.ts import { create } from 'zustand' import { createUserSlice, UserSlice } from './slices/createUserSlice' import { createCartSlice, CartSlice } from './slices/createCartSlice' type StoreState = UserSlice & CartSlice export const useStore = create<StoreState>()((...args) => ({ ...createUserSlice(...args), ...createCartSlice(...args), })) // Selective subscriptions (prevents unnecessary re-renders) export const useUser = () => useStore((state) => state.user) export const useCart = () => useStore((state) => state.cart)
Pattern 3: Jotai for Atomic State
// atoms/userAtoms.ts import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' // Basic atom export const userAtom = atom<User | null>(null) // Derived atom (computed) export const isAuthenticatedAtom = atom((get) => get(userAtom) !== null) // Atom with localStorage persistence export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light') // Async atom export const userProfileAtom = atom(async (get) => { const user = get(userAtom) if (!user) return null const response = await fetch(`/api/users/${user.id}/profile`) return response.json() }) // Write-only atom (action) export const logoutAtom = atom(null, (get, set) => { set(userAtom, null) set(cartAtom, []) localStorage.removeItem('token') }) // Usage function Profile() { const [user] = useAtom(userAtom) const [, logout] = useAtom(logoutAtom) const [profile] = useAtom(userProfileAtom) // Suspense-enabled return ( <Suspense fallback={<Skeleton />}> <ProfileContent profile={profile} onLogout={logout} /> </Suspense> ) }
Pattern 4: React Query for Server State
// hooks/useUsers.ts import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' // Query keys factory export const userKeys = { all: ['users'] as const, lists: () => [...userKeys.all, 'list'] as const, list: (filters: UserFilters) => [...userKeys.lists(), filters] as const, details: () => [...userKeys.all, 'detail'] as const, detail: (id: string) => [...userKeys.details(), id] as const, } // Fetch hook export function useUsers(filters: UserFilters) { return useQuery({ queryKey: userKeys.list(filters), queryFn: () => fetchUsers(filters), staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime) }) } // Single user hook export function useUser(id: string) { return useQuery({ queryKey: userKeys.detail(id), queryFn: () => fetchUser(id), enabled: !!id, // Don't fetch if no id }) } // Mutation with optimistic update export function useUpdateUser() { const queryClient = useQueryClient() return useMutation({ mutationFn: updateUser, onMutate: async (newUser) => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) }) // Snapshot previous value const previousUser = queryClient.getQueryData(userKeys.detail(newUser.id)) // Optimistically update queryClient.setQueryData(userKeys.detail(newUser.id), newUser) return { previousUser } }, onError: (err, newUser, context) => { // Rollback on error queryClient.setQueryData( userKeys.detail(newUser.id), context?.previousUser ) }, onSettled: (data, error, variables) => { // Refetch after mutation queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) }) }, }) }
Pattern 5: Combining Client + Server State
// Zustand for client state const useUIStore = create<UIState>((set) => ({ sidebarOpen: true, modal: null, toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), openModal: (modal) => set({ modal }), closeModal: () => set({ modal: null }), })) // React Query for server state function Dashboard() { const { sidebarOpen, toggleSidebar } = useUIStore() const { data: users, isLoading } = useUsers({ active: true }) const { data: stats } = useStats() if (isLoading) return <DashboardSkeleton /> return ( <div className={sidebarOpen ? 'with-sidebar' : ''}> <Sidebar open={sidebarOpen} onToggle={toggleSidebar} /> <main> <StatsCards stats={stats} /> <UserTable users={users} /> </main> </div> ) }
Best Practices
Do's
- Colocate state - Keep state as close to where it's used as possible
- Use selectors - Prevent unnecessary re-renders with selective subscriptions
- Normalize data - Flatten nested structures for easier updates
- Type everything - Full TypeScript coverage prevents runtime errors
- Separate concerns - Server state (React Query) vs client state (Zustand)
Don'ts
- Don't over-globalize - Not everything needs to be in global state
- Don't duplicate server state - Let React Query manage it
- Don't mutate directly - Always use immutable updates
- Don't store derived data - Compute it instead
- Don't mix paradigms - Pick one primary solution per category
Migration Guides
From Legacy Redux to RTK
// Before (legacy Redux) const ADD_TODO = 'ADD_TODO' const addTodo = (text) => ({ type: ADD_TODO, payload: text }) function todosReducer(state = [], action) { switch (action.type) { case ADD_TODO: return [...state, { text: action.payload, completed: false }] default: return state } } // After (Redux Toolkit) const todosSlice = createSlice({ name: 'todos', initialState: [], reducers: { addTodo: (state, action: PayloadAction<string>) => { // Immer allows "mutations" state.push({ text: action.payload, completed: false }) }, }, })