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