|$ curl https://forge-ai.dev/api/markdown?path=docs/react/state-management
$cat docs/state-management.md
updated Recently·40 min read·published

State Management

ReactStateArchitectureIntermediate to Advanced🎯Free Tools
Introduction

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.

Local State — useState

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.

local-state.jsx
JSX
1function 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
22function 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

Start with useState for everything. Only move to more complex solutions when you actually feel the pain — prop drilling, excessive re-renders, or duplicated state logic. Premature optimization adds complexity without benefit.
Complex State — useReducer

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.

reducer-state.jsx
JSX
1const initialState = {
2 step: 1,
3 formData: { name: "", email: "", preferences: {} },
4 errors: {},
5 isSubmitting: false,
6};
7
8function 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
46function 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

Use useReducer when you have related state that updates together, when the next state depends on the previous one in complex ways, or when you want to move state logic out of the component for testability.
Context API

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.

context-api.jsx
JSX
1import { createContext, useContext, useReducer } from "react";
2
3// 1. Create context with a default value
4const AuthContext = createContext(null);
5
6// 2. Create a provider component
7function 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
40function 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
49function 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
75function 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

Context is not a state management solution — it's a dependency injection mechanism. Every consumer re-renders when the provider value changes. For frequently changing values, this causes performance issues. Split contexts (one for user data, one for actions) or use Zustand for better performance.
Zustand — Lightweight Store

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.

zustand.jsx
JSX
1import { create } from "zustand";
2import { devtools, persist } from "zustand/middleware";
3
4// Define store with middleware
5const 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!
47function 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)
62function 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

Zustand stores are vanilla JavaScript — they work outside React, in tests, and in any framework. Each component subscribes only to the slices it uses, so re-renders are automatically optimized. This is the single biggest advantage over Context.
Redux Toolkit

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.

redux-toolkit.jsx
JSX
1import { createSlice, configureStore } from "@reduxjs/toolkit";
2import { Provider, useSelector, useDispatch } from "react-redux";
3
4// Create slice — reducers, actions, and initial state in one place
5const 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
30const fetchTodos = createAsyncThunk("todos/fetch", async () => {
31 const response = await fetch("/api/todos");
32 return response.json();
33});
34
35// Configure store
36const store = configureStore({
37 reducer: {
38 todos: todoSlice.reducer,
39 },
40});
41
42// Provide store to app
43function App() {
44 return (
45 <Provider store={store}>
46 <TodoApp />
47 </Provider>
48 );
49}
50
51// Use in components
52function 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

Redux is powerful but adds complexity. Choose Redux when you have large teams, complex state interactions, need time-travel debugging, or require extensive middleware (logging, analytics, offline support). For most apps, Zustand is simpler and sufficient.
State Management Patterns

Beyond choosing a library, effective state management requires understanding key patterns and principles:

state-patterns.jsx
JSX
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
3const useStore = create((set) => ({
4 modalOpen: false,
5 setModalOpen: (open) => set({ modalOpen: open }),
6}));
7
8function Modal() {
9 const modalOpen = useStore(state => state.modalOpen);
10 // This state only matters to this one component!
11}
12
13// ✅ Good: local state
14function 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
20const 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
31function 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
38const todoMachine = {
39 idle: { START_EDIT: "editing" },
40 editing: { SAVE: "saving", CANCEL: "idle" },
41 saving: { SUCCESS: "idle", FAILURE: "editing" },
42};
43
44function 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

Follow the "state colocation principle": put state as close as possible to where it's used. Start with useState, lift state up only when siblings need it, use Context for truly global data, and only add a state management library when you feel genuine pain.