React State Management

Manage application state with useState, useReducer, Context, Redux, or Zustand.

TL;DR

  1. 01Colocate state as close as possible to where it is used to avoid unnecessary re-renders.
  2. 02Use React Query or SWR for server state; reserve useState and Zustand for client-only state.
  3. 03Fix Redux performance with configureStore from Redux Toolkit — createStore is deprecated.

Tips

  1. 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.
  2. 02Use typed hooks (useAppSelector, useAppDispatch) rather than the raw hooks so you get full TypeScript inference without casting at every call site.
  3. 03Start with useState and useContext, only add Redux or Zustand when state becomes too complex to manage.

Warnings

  1. 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.assign or 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 needed
    
  • Use React.memo plus 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 configureStorecreateStore is 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 createSlice to 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 useSelector and dispatch actions with useDispatch.

    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 create call.

    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