Back to Blog
ReactState ManagementZustandRedux

React State Management: Choosing the Right Approach

Manage state effectively in React. From useState to Context to Zustand to when you actually need Redux.

B
Bootspring Team
Engineering
August 28, 2023
6 min read

State management in React ranges from simple useState to complex global stores. The key is using the right tool for each situation—most apps need less than you think.

State Categories#

Local State: - Form inputs - UI toggles - Component-specific data → useState, useReducer Shared State: - Theme, locale - User session - Feature flags → Context, Zustand Server State: - API responses - Cached data - Loading states → TanStack Query, SWR URL State: - Current page - Filters, search - Shareable state → Router params, searchParams

useState for Local State#

1// Simple local state 2function Counter() { 3 const [count, setCount] = useState(0); 4 5 return ( 6 <button onClick={() => setCount((c) => c + 1)}> 7 Count: {count} 8 </button> 9 ); 10} 11 12// Object state 13function Form() { 14 const [form, setForm] = useState({ 15 name: '', 16 email: '', 17 }); 18 19 const updateField = (field: string, value: string) => { 20 setForm((prev) => ({ ...prev, [field]: value })); 21 }; 22 23 return ( 24 <form> 25 <input 26 value={form.name} 27 onChange={(e) => updateField('name', e.target.value)} 28 /> 29 <input 30 value={form.email} 31 onChange={(e) => updateField('email', e.target.value)} 32 /> 33 </form> 34 ); 35}

useReducer for Complex State#

1interface State { 2 items: Item[]; 3 loading: boolean; 4 error: string | null; 5 filter: string; 6} 7 8type Action = 9 | { type: 'FETCH_START' } 10 | { type: 'FETCH_SUCCESS'; payload: Item[] } 11 | { type: 'FETCH_ERROR'; payload: string } 12 | { type: 'SET_FILTER'; payload: string } 13 | { type: 'ADD_ITEM'; payload: Item } 14 | { type: 'REMOVE_ITEM'; payload: string }; 15 16function reducer(state: State, action: Action): State { 17 switch (action.type) { 18 case 'FETCH_START': 19 return { ...state, loading: true, error: null }; 20 case 'FETCH_SUCCESS': 21 return { ...state, loading: false, items: action.payload }; 22 case 'FETCH_ERROR': 23 return { ...state, loading: false, error: action.payload }; 24 case 'SET_FILTER': 25 return { ...state, filter: action.payload }; 26 case 'ADD_ITEM': 27 return { ...state, items: [...state.items, action.payload] }; 28 case 'REMOVE_ITEM': 29 return { 30 ...state, 31 items: state.items.filter((i) => i.id !== action.payload), 32 }; 33 default: 34 return state; 35 } 36} 37 38function ItemList() { 39 const [state, dispatch] = useReducer(reducer, { 40 items: [], 41 loading: false, 42 error: null, 43 filter: '', 44 }); 45 46 useEffect(() => { 47 dispatch({ type: 'FETCH_START' }); 48 fetchItems() 49 .then((items) => dispatch({ type: 'FETCH_SUCCESS', payload: items })) 50 .catch((err) => dispatch({ type: 'FETCH_ERROR', payload: err.message })); 51 }, []); 52 53 // ... 54}

Context for Shared State#

1// Theme context 2interface ThemeContextType { 3 theme: 'light' | 'dark'; 4 toggleTheme: () => void; 5} 6 7const ThemeContext = createContext<ThemeContextType | null>(null); 8 9function ThemeProvider({ children }: { children: React.ReactNode }) { 10 const [theme, setTheme] = useState<'light' | 'dark'>('light'); 11 12 const toggleTheme = useCallback(() => { 13 setTheme((t) => (t === 'light' ? 'dark' : 'light')); 14 }, []); 15 16 return ( 17 <ThemeContext.Provider value={{ theme, toggleTheme }}> 18 {children} 19 </ThemeContext.Provider> 20 ); 21} 22 23function useTheme() { 24 const context = useContext(ThemeContext); 25 if (!context) { 26 throw new Error('useTheme must be used within ThemeProvider'); 27 } 28 return context; 29} 30 31// Usage 32function Header() { 33 const { theme, toggleTheme } = useTheme(); 34 35 return ( 36 <header className={theme}> 37 <button onClick={toggleTheme}>Toggle Theme</button> 38 </header> 39 ); 40}

Zustand for Global State#

1import { create } from 'zustand'; 2import { persist } from 'zustand/middleware'; 3 4interface CartStore { 5 items: CartItem[]; 6 addItem: (item: CartItem) => void; 7 removeItem: (id: string) => void; 8 updateQuantity: (id: string, quantity: number) => void; 9 clear: () => void; 10 total: () => number; 11} 12 13const useCartStore = create<CartStore>()( 14 persist( 15 (set, get) => ({ 16 items: [], 17 18 addItem: (item) => 19 set((state) => { 20 const existing = state.items.find((i) => i.id === item.id); 21 if (existing) { 22 return { 23 items: state.items.map((i) => 24 i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i 25 ), 26 }; 27 } 28 return { items: [...state.items, { ...item, quantity: 1 }] }; 29 }), 30 31 removeItem: (id) => 32 set((state) => ({ 33 items: state.items.filter((i) => i.id !== id), 34 })), 35 36 updateQuantity: (id, quantity) => 37 set((state) => ({ 38 items: state.items.map((i) => 39 i.id === id ? { ...i, quantity } : i 40 ), 41 })), 42 43 clear: () => set({ items: [] }), 44 45 total: () => { 46 const { items } = get(); 47 return items.reduce((sum, i) => sum + i.price * i.quantity, 0); 48 }, 49 }), 50 { 51 name: 'cart-storage', 52 } 53 ) 54); 55 56// Usage - no provider needed! 57function CartButton() { 58 const itemCount = useCartStore((s) => s.items.length); 59 return <button>Cart ({itemCount})</button>; 60} 61 62function CartTotal() { 63 const total = useCartStore((s) => s.total()); 64 return <div>Total: ${total}</div>; 65}

TanStack Query for Server State#

1import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 2 3function useUsers() { 4 return useQuery({ 5 queryKey: ['users'], 6 queryFn: () => fetch('/api/users').then((r) => r.json()), 7 staleTime: 5 * 60 * 1000, // 5 minutes 8 }); 9} 10 11function useCreateUser() { 12 const queryClient = useQueryClient(); 13 14 return useMutation({ 15 mutationFn: (newUser: CreateUserInput) => 16 fetch('/api/users', { 17 method: 'POST', 18 body: JSON.stringify(newUser), 19 }).then((r) => r.json()), 20 21 onSuccess: () => { 22 queryClient.invalidateQueries({ queryKey: ['users'] }); 23 }, 24 }); 25} 26 27function UserList() { 28 const { data: users, isLoading, error } = useUsers(); 29 const createUser = useCreateUser(); 30 31 if (isLoading) return <Loading />; 32 if (error) return <Error error={error} />; 33 34 return ( 35 <div> 36 <button 37 onClick={() => createUser.mutate({ name: 'New User' })} 38 disabled={createUser.isPending} 39 > 40 Add User 41 </button> 42 43 {users.map((user) => ( 44 <UserCard key={user.id} user={user} /> 45 ))} 46 </div> 47 ); 48}

Decision Guide#

Use useState when: - State is local to component - State is simple (primitives, small objects) - No need to share with siblings Use useReducer when: - Complex state logic - Multiple related values - Next state depends on previous Use Context when: - Theme, locale, auth - Infrequently updated state - Avoid prop drilling (2-3 levels) Use Zustand when: - Global app state - Frequent updates - Need persistence - Don't want providers Use TanStack Query when: - API data fetching - Caching needed - Background refetching - Optimistic updates Skip Redux unless: - Large team needs conventions - Complex middleware requirements - Time-travel debugging critical

Best Practices#

DO: ✓ Start with useState ✓ Colocate state with UI ✓ Split state by domain ✓ Use server state libraries for API data ✓ Derive state when possible DON'T: ✗ Put everything in global state ✗ Use Context for frequently updating state ✗ Duplicate server state in client state ✗ Reach for Redux by default

Conclusion#

Most React apps need less state management than you think. Use useState for local state, TanStack Query for server state, and Zustand for the rare global client state.

Start simple and add complexity only when needed.

Share this article

Help spread the word about Bootspring