State Management
State management is how your application tracks and responds to changes in data. In React, state drives the UI — when state changes, components re-render to reflect the new data. Choosing the right state management strategy depends on your app's complexity, the scope of shared data, and performance requirements.
The spectrum ranges from simple (local useState) to complex (Redux, Zustand). Most applications only need useState + Context. Only reach for external libraries when you have genuine needs for global state, complex update logic, or performance optimization.
useStateis the simplest form of state management. It lives within a single component and is the right choice for UI state that doesn't need to be shared: form inputs, toggle states, local data, and transient UI changes.
| 1 | function SearchBar() { |
| 2 | const [query, setQuery] = useState(""); |
| 3 | const [isFocused, setIsFocused] = useState(false); |
| 4 | |
| 5 | return ( |
| 6 | <div className={`search-bar ${isFocused ? "focused" : ""}`}> |
| 7 | <input |
| 8 | value={query} |
| 9 | onChange={e => setQuery(e.target.value)} |
| 10 | onFocus={() => setIsFocused(true)} |
| 11 | onBlur={() => setIsFocused(false)} |
| 12 | placeholder="Search..." |
| 13 | /> |
| 14 | {query && ( |
| 15 | <button onClick={() => setQuery("")}>Clear</button> |
| 16 | )} |
| 17 | </div> |
| 18 | ); |
| 19 | } |
| 20 | |
| 21 | // Lifting state up — share between siblings |
| 22 | function TemperatureConverter() { |
| 23 | const [celsius, setCelsius] = useState(0); |
| 24 | |
| 25 | const fahrenheit = (celsius * 9) / 5 + 32; |
| 26 | |
| 27 | return ( |
| 28 | <div> |
| 29 | <TemperatureInput |
| 30 | label="Celsius" |
| 31 | value={celsius} |
| 32 | onChange={setCelsius} |
| 33 | /> |
| 34 | <TemperatureInput |
| 35 | label="Fahrenheit" |
| 36 | value={fahrenheit} |
| 37 | onChange={f => setCelsius(((f - 32) * 5) / 9)} |
| 38 | /> |
| 39 | </div> |
| 40 | ); |
| 41 | } |
info
When state logic involves multiple sub-values or complex transitions, useReducer provides a cleaner alternative to multiple useState calls. It centralizes state update logic in a reducer function.
| 1 | const initialState = { |
| 2 | step: 1, |
| 3 | formData: { name: "", email: "", preferences: {} }, |
| 4 | errors: {}, |
| 5 | isSubmitting: false, |
| 6 | }; |
| 7 | |
| 8 | function formReducer(state, action) { |
| 9 | switch (action.type) { |
| 10 | case "UPDATE_FIELD": |
| 11 | return { |
| 12 | ...state, |
| 13 | formData: { ...state.formData, [action.field]: action.value }, |
| 14 | errors: { ...state.errors, [action.field]: undefined }, |
| 15 | }; |
| 16 | case "UPDATE_PREFERENCE": |
| 17 | return { |
| 18 | ...state, |
| 19 | formData: { |
| 20 | ...state.formData, |
| 21 | preferences: { |
| 22 | ...state.formData.preferences, |
| 23 | [action.key]: action.value, |
| 24 | }, |
| 25 | }, |
| 26 | }; |
| 27 | case "SET_ERRORS": |
| 28 | return { ...state, errors: action.errors }; |
| 29 | case "NEXT_STEP": |
| 30 | return { ...state, step: Math.min(state.step + 1, 3) }; |
| 31 | case "PREV_STEP": |
| 32 | return { ...state, step: Math.max(state.step - 1, 1) }; |
| 33 | case "SUBMIT_START": |
| 34 | return { ...state, isSubmitting: true }; |
| 35 | case "SUBMIT_SUCCESS": |
| 36 | return { ...state, isSubmitting: false, step: 1, formData: initialState.formData }; |
| 37 | case "SUBMIT_FAILURE": |
| 38 | return { ...state, isSubmitting: false, errors: action.errors }; |
| 39 | case "RESET": |
| 40 | return initialState; |
| 41 | default: |
| 42 | return state; |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | function MultiStepForm() { |
| 47 | const [state, dispatch] = useReducer(formReducer, initialState); |
| 48 | |
| 49 | const handleSubmit = async () => { |
| 50 | dispatch({ type: "SUBMIT_START" }); |
| 51 | try { |
| 52 | await submitForm(state.formData); |
| 53 | dispatch({ type: "SUBMIT_SUCCESS" }); |
| 54 | } catch (err) { |
| 55 | dispatch({ type: "SUBMIT_FAILURE", errors: err.fieldErrors }); |
| 56 | } |
| 57 | }; |
| 58 | |
| 59 | return ( |
| 60 | <form onSubmit={e => { e.preventDefault(); handleSubmit(); }}> |
| 61 | {state.step === 1 && ( |
| 62 | <Step1 |
| 63 | data={state.formData} |
| 64 | errors={state.errors} |
| 65 | onChange={(field, value) => dispatch({ type: "UPDATE_FIELD", field, value })} |
| 66 | /> |
| 67 | )} |
| 68 | {state.step === 2 && ( |
| 69 | <Step2 |
| 70 | preferences={state.formData.preferences} |
| 71 | onChange={(key, value) => dispatch({ type: "UPDATE_PREFERENCE", key, value })} |
| 72 | /> |
| 73 | )} |
| 74 | {state.step === 3 && <Review data={state.formData} />} |
| 75 | <div> |
| 76 | {state.step > 1 && <button onClick={() => dispatch({ type: "PREV_STEP" })}>Back</button>} |
| 77 | {state.step < 3 && <button onClick={() => dispatch({ type: "NEXT_STEP" })}>Next</button>} |
| 78 | {state.step === 3 && <button type="submit" disabled={state.isSubmitting}>Submit</button>} |
| 79 | </div> |
| 80 | </form> |
| 81 | ); |
| 82 | } |
best practice
React Context provides a way to pass data through the component tree without manually passing props at every level. It's designed for values that many components need: themes, authentication, locale, and app-wide settings.
| 1 | import { createContext, useContext, useReducer } from "react"; |
| 2 | |
| 3 | // 1. Create context with a default value |
| 4 | const AuthContext = createContext(null); |
| 5 | |
| 6 | // 2. Create a provider component |
| 7 | function AuthProvider({ children }) { |
| 8 | const [state, dispatch] = useReducer(authReducer, { |
| 9 | user: null, |
| 10 | token: null, |
| 11 | isAuthenticated: false, |
| 12 | isLoading: false, |
| 13 | }); |
| 14 | |
| 15 | const login = async (email, password) => { |
| 16 | dispatch({ type: "LOGIN_START" }); |
| 17 | try { |
| 18 | const { user, token } = await api.login(email, password); |
| 19 | dispatch({ type: "LOGIN_SUCCESS", payload: { user, token } }); |
| 20 | } catch (error) { |
| 21 | dispatch({ type: "LOGIN_FAILURE", payload: error.message }); |
| 22 | throw error; |
| 23 | } |
| 24 | }; |
| 25 | |
| 26 | const logout = () => { |
| 27 | dispatch({ type: "LOGOUT" }); |
| 28 | }; |
| 29 | |
| 30 | const value = { ...state, login, logout }; |
| 31 | |
| 32 | return ( |
| 33 | <AuthContext.Provider value={value}> |
| 34 | {children} |
| 35 | </AuthContext.Provider> |
| 36 | ); |
| 37 | } |
| 38 | |
| 39 | // 3. Create a custom hook for consuming context |
| 40 | function useAuth() { |
| 41 | const context = useContext(AuthContext); |
| 42 | if (!context) { |
| 43 | throw new Error("useAuth must be used within an AuthProvider"); |
| 44 | } |
| 45 | return context; |
| 46 | } |
| 47 | |
| 48 | // 4. Usage in components |
| 49 | function LoginPage() { |
| 50 | const { login, isLoading } = useAuth(); |
| 51 | const [email, setEmail] = useState(""); |
| 52 | const [password, setPassword] = useState(""); |
| 53 | |
| 54 | const handleSubmit = async (e) => { |
| 55 | e.preventDefault(); |
| 56 | try { |
| 57 | await login(email, password); |
| 58 | router.push("/dashboard"); |
| 59 | } catch (err) { |
| 60 | alert(err.message); |
| 61 | } |
| 62 | }; |
| 63 | |
| 64 | return ( |
| 65 | <form onSubmit={handleSubmit}> |
| 66 | <input value={email} onChange={e => setEmail(e.target.value)} /> |
| 67 | <input type="password" value={password} onChange={e => setPassword(e.target.value)} /> |
| 68 | <button disabled={isLoading}> |
| 69 | {isLoading ? "Logging in..." : "Login"} |
| 70 | </button> |
| 71 | </form> |
| 72 | ); |
| 73 | } |
| 74 | |
| 75 | function ProtectedRoute({ children }) { |
| 76 | const { isAuthenticated, isLoading } = useAuth(); |
| 77 | |
| 78 | if (isLoading) return <Spinner />; |
| 79 | if (!isAuthenticated) return <Navigate to="/login" />; |
| 80 | |
| 81 | return children; |
| 82 | } |
warning
Zustand is a small, fast, scalable state management library. It uses hooks and requires no providers — components subscribe to the store directly. It's the recommended choice for most React applications that need global state.
| 1 | import { create } from "zustand"; |
| 2 | import { devtools, persist } from "zustand/middleware"; |
| 3 | |
| 4 | // Define store with middleware |
| 5 | const useStore = create( |
| 6 | devtools( |
| 7 | persist( |
| 8 | (set, get) => ({ |
| 9 | // State |
| 10 | count: 0, |
| 11 | user: null, |
| 12 | todos: [], |
| 13 | |
| 14 | // Actions (automatically bound) |
| 15 | increment: () => set(state => ({ count: state.count + 1 })), |
| 16 | decrement: () => set(state => ({ count: state.count - 1 })), |
| 17 | reset: () => set({ count: 0 }), |
| 18 | |
| 19 | // Async actions |
| 20 | fetchUser: async (id) => { |
| 21 | const user = await fetch(`/api/users/${id}`).then(r => r.json()); |
| 22 | set({ user }); |
| 23 | }, |
| 24 | |
| 25 | // Derived state (computed) |
| 26 | getCompletedTodos: () => get().todos.filter(t => t.done), |
| 27 | |
| 28 | // Complex updates |
| 29 | addTodo: (text) => |
| 30 | set(state => ({ |
| 31 | todos: [...state.todos, { id: Date.now(), text, done: false }], |
| 32 | })), |
| 33 | toggleTodo: (id) => |
| 34 | set(state => ({ |
| 35 | todos: state.todos.map(t => |
| 36 | t.id === id ? { ...t, done: !t.done } : t |
| 37 | ), |
| 38 | })), |
| 39 | }), |
| 40 | { name: "app-store" } // persist to localStorage |
| 41 | ), |
| 42 | { name: "AppStore" } // devtools label |
| 43 | ) |
| 44 | ); |
| 45 | |
| 46 | // Usage in components — no provider needed! |
| 47 | function Counter() { |
| 48 | const count = useStore(state => state.count); |
| 49 | const increment = useStore(state => state.increment); |
| 50 | const decrement = useStore(state => state.decrement); |
| 51 | |
| 52 | return ( |
| 53 | <div> |
| 54 | <span>{count}</span> |
| 55 | <button onClick={increment}>+</button> |
| 56 | <button onClick={decrement}>-</button> |
| 57 | </div> |
| 58 | ); |
| 59 | } |
| 60 | |
| 61 | // Select multiple values (auto-batches updates) |
| 62 | function TodoList() { |
| 63 | const todos = useStore(state => state.todos); |
| 64 | const addTodo = useStore(state => state.addTodo); |
| 65 | const toggleTodo = useStore(state => state.toggleTodo); |
| 66 | |
| 67 | return ( |
| 68 | <div> |
| 69 | {todos.map(todo => ( |
| 70 | <div key={todo.id} onClick={() => toggleTodo(todo.id)}> |
| 71 | {todo.done ? "✓" : "○"} {todo.text} |
| 72 | </div> |
| 73 | ))} |
| 74 | <button onClick={() => addTodo("New item")}>Add</button> |
| 75 | </div> |
| 76 | ); |
| 77 | } |
pro tip
Redux Toolkit (RTK) is the official, recommended way to write Redux logic. It simplifies store setup, reduces boilerplate, and includes immutable update logic with Immer. Use Redux when you need predictable state, time-travel debugging, and extensive middleware.
| 1 | import { createSlice, configureStore } from "@reduxjs/toolkit"; |
| 2 | import { Provider, useSelector, useDispatch } from "react-redux"; |
| 3 | |
| 4 | // Create slice — reducers, actions, and initial state in one place |
| 5 | const todoSlice = createSlice({ |
| 6 | name: "todos", |
| 7 | initialState: { items: [], filter: "all" }, |
| 8 | reducers: { |
| 9 | addTodo: (state, action) => { |
| 10 | state.items.push({ |
| 11 | id: Date.now(), |
| 12 | text: action.payload, |
| 13 | done: false, |
| 14 | }); |
| 15 | }, |
| 16 | toggleTodo: (state, action) => { |
| 17 | const todo = state.items.find(t => t.id === action.payload); |
| 18 | if (todo) todo.done = !todo.done; // Immer makes this safe! |
| 19 | }, |
| 20 | removeTodo: (state, action) => { |
| 21 | state.items = state.items.filter(t => t.id !== action.payload); |
| 22 | }, |
| 23 | setFilter: (state, action) => { |
| 24 | state.filter = action.payload; |
| 25 | }, |
| 26 | }, |
| 27 | }); |
| 28 | |
| 29 | // Async thunk |
| 30 | const fetchTodos = createAsyncThunk("todos/fetch", async () => { |
| 31 | const response = await fetch("/api/todos"); |
| 32 | return response.json(); |
| 33 | }); |
| 34 | |
| 35 | // Configure store |
| 36 | const store = configureStore({ |
| 37 | reducer: { |
| 38 | todos: todoSlice.reducer, |
| 39 | }, |
| 40 | }); |
| 41 | |
| 42 | // Provide store to app |
| 43 | function App() { |
| 44 | return ( |
| 45 | <Provider store={store}> |
| 46 | <TodoApp /> |
| 47 | </Provider> |
| 48 | ); |
| 49 | } |
| 50 | |
| 51 | // Use in components |
| 52 | function TodoApp() { |
| 53 | const dispatch = useDispatch(); |
| 54 | const { items, filter } = useSelector(state => state.todos); |
| 55 | const [input, setInput] = useState(""); |
| 56 | |
| 57 | const filteredTodos = items.filter(todo => { |
| 58 | if (filter === "active") return !todo.done; |
| 59 | if (filter === "completed") return todo.done; |
| 60 | return true; |
| 61 | }); |
| 62 | |
| 63 | return ( |
| 64 | <div> |
| 65 | <input value={input} onChange={e => setInput(e.target.value)} /> |
| 66 | <button onClick={() => { |
| 67 | dispatch(todoSlice.actions.addTodo(input)); |
| 68 | setInput(""); |
| 69 | }}>Add</button> |
| 70 | {filteredTodos.map(todo => ( |
| 71 | <div key={todo.id} onClick={() => dispatch(todoSlice.actions.toggleTodo(todo.id))}> |
| 72 | {todo.done ? "✓" : "○"} {todo.text} |
| 73 | </div> |
| 74 | ))} |
| 75 | </div> |
| 76 | ); |
| 77 | } |
note
Beyond choosing a library, effective state management requires understanding key patterns and principles:
| 1 | // Pattern 1: Colocate state — keep it as close as possible to where it's used |
| 2 | // ❌ Bad: lifting state to a global store unnecessarily |
| 3 | const useStore = create((set) => ({ |
| 4 | modalOpen: false, |
| 5 | setModalOpen: (open) => set({ modalOpen: open }), |
| 6 | })); |
| 7 | |
| 8 | function Modal() { |
| 9 | const modalOpen = useStore(state => state.modalOpen); |
| 10 | // This state only matters to this one component! |
| 11 | } |
| 12 | |
| 13 | // ✅ Good: local state |
| 14 | function Modal() { |
| 15 | const [isOpen, setIsOpen] = useState(false); |
| 16 | } |
| 17 | |
| 18 | // Pattern 2: Derived state — compute during render, don't store |
| 19 | // ❌ Bad: storing derived data |
| 20 | const useStore = create((set, get) => ({ |
| 21 | items: [], |
| 22 | filteredItems: [], // duplicate! |
| 23 | filter: "", |
| 24 | setFilter: (filter) => set({ |
| 25 | filter, |
| 26 | filteredItems: get().items.filter(i => i.matches(filter)), |
| 27 | }), |
| 28 | })); |
| 29 | |
| 30 | // ✅ Good: compute during render |
| 31 | function FilteredList() { |
| 32 | const items = useStore(state => state.items); |
| 33 | const filter = useStore(state => state.filter); |
| 34 | const filteredItems = useMemo(() => items.filter(i => i.matches(filter)), [items, filter]); |
| 35 | } |
| 36 | |
| 37 | // Pattern 3: State machines — prevent impossible states |
| 38 | const todoMachine = { |
| 39 | idle: { START_EDIT: "editing" }, |
| 40 | editing: { SAVE: "saving", CANCEL: "idle" }, |
| 41 | saving: { SUCCESS: "idle", FAILURE: "editing" }, |
| 42 | }; |
| 43 | |
| 44 | function useTodoState(initialState = "idle") { |
| 45 | const [state, setState] = useState(initialState); |
| 46 | const [machine, setMachine] = useState(todoMachine); |
| 47 | |
| 48 | const send = (event) => { |
| 49 | const nextState = machine[state]?.[event]; |
| 50 | if (nextState) setState(nextState); |
| 51 | }; |
| 52 | |
| 53 | return { state, send }; |
| 54 | } |
best practice