React State Management
Manage application state with useState, useReducer, Context, Redux, or Zustand.
TL;DR
- 01Colocate state as close as possible to where it is used to avoid unnecessary re-renders.
- 02Use React Query or SWR for server state; reserve useState and Zustand for client-only state.
- 03Fix Redux performance with configureStore from Redux Toolkit — createStore is deprecated.
Tips
- 01Most apps have far less global state than developers think — UI state like hover, focus, and toggles almost always belongs locally, not in a shared store.
- 02Use typed hooks (useAppSelector, useAppDispatch) rather than the raw hooks so you get full TypeScript inference without casting at every call site.
- 03Start with useState and useContext, only add Redux or Zustand when state becomes too complex to manage.
Warnings
- 01Don't over-engineer state management — use the simplest solution that works for your app size.
Server State vs Client State
Server state is data that lives on the server and needs to stay in sync — fetch it with React Query or SWR, not useState.
import { useQuery } from "@tanstack/react-query"; function UserProfile({ id }: { id: string }) { const { data, isLoading, error } = useQuery({ queryKey: ["user", id], queryFn: () => fetch(`/api/users/${id}`).then(r => r.json()), staleTime: 60_000 // cache for 1 minute }); if (isLoading) return <p>Loading...</p>; if (error) return <p>Error loading user.</p>; return <h1>{data.name}</h1>; }Client state is UI-only data that doesn't need to persist on the server — modal open/closed, selected tab, input value.
// Client state: lives in the component, no server sync needed const [isMenuOpen, setIsMenuOpen] = useState(false); const [activeTab, setActiveTab] = useState("overview");Use SWR for lightweight data fetching with automatic revalidation on focus and reconnect.
import useSWR from "swr"; const fetcher = (url: string) => fetch(url).then(r => r.json()); function Dashboard() { const { data } = useSWR("/api/stats", fetcher, { refreshInterval: 30000 }); return <p>Users: {data?.count}</p>; }Avoid duplicating server state in useState — let the fetching library own it and read from its cache.
// Wrong: duplicating server data into local state const [user, setUser] = useState(null); useEffect(() => { fetch("/api/me").then(r => r.json()).then(setUser); }, []); // Right: let React Query own the state const { data: user } = useQuery({ queryKey: ["me"], queryFn: () => fetch("/api/me").then(r => r.json()) });Invalidate cache entries after mutations so the UI stays in sync without manual state updates.
const queryClient = useQueryClient(); await updateUser(data); queryClient.invalidateQueries({ queryKey: ["user", id] }); // refetches automatically
State Colocation
Place state as close as possible to where it is used — don't lift state higher than necessary.
// Wrong: lifting filter state to the top-level App when only ProductList needs it function App() { const [filter, setFilter] = useState("all"); return <ProductList filter={filter} onFilterChange={setFilter} />; } // Right: colocate filter state inside ProductList where it belongs function ProductList() { const [filter, setFilter] = useState("all"); // ...no prop drilling needed }State used only in one component belongs in that component, not in a parent or global store.
State used by two sibling components should live in their closest common ancestor (lift state up one level, not all the way up).
// Both SearchBox and SearchResults need the query — lift to their parent function SearchPage() { const [query, setQuery] = useState(""); return ( <> <SearchBox value={query} onChange={setQuery} /> <SearchResults query={query} /> </> ); }State used across unrelated parts of the app belongs in a global store (Zustand, Context, or Redux).
Avoid premature globalization — start local and lift only when a second consumer appears.
// Progression: local → lifted → global // 1. useState in component (local) // 2. useState in parent (lifted) — when a sibling needs it // 3. Zustand or Context (global) — when distant components need it
State Performance Patterns
Use functional state updates when new state depends on previous state to avoid stale closures.
// Wrong: may read stale count if React batches updates setCount(count + 1); // Right: always uses the latest committed state setCount(prev => prev + 1); setItems(prev => [...prev, newItem]);Batch related state updates with
Object.assignor a single state object to avoid extra renders.// Two setState calls = two renders setLoading(true); setError(null); // One setState call = one render setState(prev => ({ ...prev, loading: true, error: null }));Use Zustand's selector to subscribe only to the slice of state a component needs.
const useStore = create((set) => ({ count: 0, user: null, theme: "light", setCount: (n) => set({ count: n }), })); // Only re-renders when count changes, not when user or theme changes const count = useStore(state => state.count);Derive values during render instead of storing derived state — computed values don't need setState.
// Wrong: storing derived state const [items, setItems] = useState([]); const [count, setCount] = useState(0); // always equals items.length // Right: derive during render const [items, setItems] = useState([]); const count = items.length; // computed, always accurate, no sync neededUse
React.memoplus stable callback references to prevent child re-renders from parent state changes.const handleClick = useCallback(() => dispatch({ type: "SUBMIT" }), [dispatch]); const ExpensiveChild = React.memo(({ onClick }) => <button onClick={onClick}>Submit</button>);
Redux for Large Apps
Use Redux Toolkit's
configureStore—createStoreis deprecated since Redux 4.x.import { configureStore } from "@reduxjs/toolkit"; import counterReducer from "./counterSlice"; export const store = configureStore({ reducer: { counter: counterReducer } }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;Define slices with
createSliceto eliminate action type constants and manual spread reducers.import { createSlice } from "@reduxjs/toolkit"; const counterSlice = createSlice({ name: "counter", initialState: { value: 0 }, reducers: { increment: state => { state.value += 1; }, // Immer allows direct mutation decrement: state => { state.value -= 1; }, setValue: (state, action) => { state.value = action.payload; } } }); export const { increment, decrement, setValue } = counterSlice.actions; export default counterSlice.reducer;Wrap your app in
<Provider store={store}>to make state available to all components.import { Provider } from "react-redux"; root.render(<Provider store={store}><App /></Provider>);Read state with
useSelectorand dispatch actions withuseDispatch.function Counter() { const count = useSelector((state: RootState) => state.counter.value); const dispatch = useDispatch<AppDispatch>(); return <button onClick={() => dispatch(increment())}>{count}</button>; }Use Redux DevTools to inspect state history, replay actions, and debug time-travel in development.
Lightweight Alternatives
Create a Zustand store with state and actions in a single
createcall.import { create } from "zustand"; const useStore = create<{ count: number; increment: () => void }>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), })); function Counter() { const { count, increment } = useStore(); return <button onClick={increment}>Count: {count}</button>; }Use Jotai atoms for fine-grained, component-level shared state without a central store.
import { atom, useAtom } from "jotai"; const countAtom = atom(0); function Counter() { const [count, setCount] = useAtom(countAtom); return <button onClick={() => setCount((c) => c + 1)}>{count}</button>; }Use Recoil atoms and selectors to share state and compute derived values declaratively.
import { atom, selector } from "recoil"; const textState = atom({ key: "textState", default: "" }); const charCountState = selector({ key: "charCountState", get: ({ get }) => get(textState).length, // derived from textState });Access Zustand state anywhere without wrapping the app in a Provider.
// No <Provider> needed — call the hook directly in any component function Navbar() { const user = useStore((state) => state.user); // selector for one slice return <p>Hello {user?.name}</p>; }Choose Zustand or Jotai over Redux when you want minimal boilerplate for small to medium apps.
// Redux: actions + reducer + selectors + Provider setup // Zustand: one create() call, one hook, no Provider required // Jotai: one atom() call, one useAtom() hook, no Provider required const theme = useStore((s) => s.theme); // direct selector access
FAQ
Use useReducer when you have multiple related state values that change together, or when next state depends on the previous state in complex ways. If you find yourself writing several useState calls that always update together, that's a signal to consolidate with useReducer.
Context API replaces Redux for many medium-sized apps, but it's not optimized for high-frequency updates — every consumer re-renders when context value changes. For apps with frequent global state updates or large teams needing strict state patterns, Redux or Zustand will perform better.
Zustand has a much smaller API surface and requires no boilerplate — you define a store in a single function call versus Redux's actions, reducers, and selectors. Choose Zustand for smaller teams or projects where Redux's structure feels like overhead.
Any state change in the context value causes all consumers to re-render, even if they only use a part of the value. Fix this by splitting large contexts into smaller focused ones, or memoizing the context value with useMemo.
Yes, and it's often the right call — local UI state in useState, shared server data in React Query or SWR, and global client state in Context or Zustand can coexist cleanly. Mixing solutions by concern keeps each layer simple rather than forcing everything through one system.