Skip to main content

State Management Patterns in React

Answer​

React offers multiple state management approaches. Choosing the right one depends on the app's complexity, team preferences, and specific requirements.

State Management Spectrum​

Local State (useState/useReducer)​

// Simple local state
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

// Complex local state with reducer
function TodoList() {
const [todos, dispatch] = useReducer(todoReducer, []);

return (
<>
<button onClick={() => dispatch({ type: "ADD", text: "New" })}>
Add
</button>
{todos.map((todo) => (
<Todo
key={todo.id}
todo={todo}
onToggle={() => dispatch({ type: "TOGGLE", id: todo.id })}
/>
))}
</>
);
}

Context for Global State​

// Combine useReducer with Context
const StateContext = createContext(null);
const DispatchContext = createContext(null);

function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);

return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}

// Custom hooks for clean consumption
function useAppState() {
return useContext(StateContext);
}

function useAppDispatch() {
return useContext(DispatchContext);
}

Zustand (Minimal External Store)​

import { create } from "zustand";

const useStore = create((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),

// Async actions
fetchCount: async () => {
const count = await api.getCount();
set({ count });
},
}));

// Usage - only subscribes to what component uses
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

return <button onClick={increment}>{count}</button>;
}

Redux Toolkit (Complex Apps)​

import { createSlice, configureStore } from "@reduxjs/toolkit";

// Slice
const counterSlice = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});

// Store
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
});

// Usage with hooks
function Counter() {
const count = useSelector((state) => state.counter.value);
const dispatch = useDispatch();

return (
<button onClick={() => dispatch(counterSlice.actions.increment())}>
{count}
</button>
);
}

Server State (React Query)​

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function UserList() {
// Fetching
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
});

// Mutations
const queryClient = useQueryClient();

const createMutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});

if (isLoading) return <Spinner />;
if (error) return <Error error={error} />;

return (
<>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
<button onClick={() => createMutation.mutate({ name: "New User" })}>
Add User
</button>
</>
);
}

When to Use What​

ScenarioSolution
Form stateLocal useState
UI state (modals, tabs)Local useState
Theme, authContext
Complex UI stateuseReducer + Context
Large app, time-travel debugRedux
Minimal global stateZustand, Jotai
Server data (CRUD)React Query, SWR
Real-time dataReact Query + WebSocket

Pattern: Colocating State​

// ✅ Keep state close to where it's used
function SearchPage() {
return (
<div>
<SearchBar /> {/* Has its own search state */}
<Results /> {/* Has its own pagination state */}
</div>
);
}

// ❌ Don't put everything in global store
// - Authentication status ✅ global
// - Modal open/close ❌ local
// - Form input values ❌ local
// - API response data ✅ React Query

Pattern: Derived State​

function ShoppingCart({ items }) {
// ❌ Don't store derived values
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);

// ✅ Calculate during render
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price, 0),
[items]
);
}

Combining Approaches​

// Modern React app typically uses:
// 1. Local state for UI
// 2. React Query for server state
// 3. Context or Zustand for shared client state

function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
{" "}
{/* Context for auth */}
<ThemeProvider>
{" "}
{/* Context for theme */}
<Router>
<Routes />
</Router>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}

Key Points​

  • Start with local state, lift when needed
  • Context works for infrequent updates
  • React Query/SWR for server state
  • Zustand for simple global client state
  • Redux for complex apps needing DevTools
  • Don't store derived state
  • Colocate state with components that use it