Featured image of post React State Management in 2024: Choosing the Right Tool Featured image of post React State Management in 2024: Choosing the Right Tool

React State Management in 2024: Choosing the Right Tool

Compare React state management options in 2024: useState, useReducer, useContext, Zustand, Jotai, and TanStack Query with performance patterns.

By 2024, React’s state management landscape has matured to the point where choosing the right tool is a matter of understanding state categories rather than picking a winner. The core library provides useState and useReducer for local concerns, while the ecosystem offers specialized solutions for global UI state and server state. The key insight is that different categories of state benefit from different tools, and mixing them appropriately produces the most maintainable applications.

useState and useReducer: Built-in Simplicity

For component-local state, useState remains the most appropriate default. It handles form inputs, toggle states, and simple UI state effectively. useReducer becomes valuable when state logic involves multiple sub-values or complex transitions.

// Simple counter
const [count, setCount] = useState(0);

// Form state
const [form, setForm] = useState({ name: "", email: "" });
// Complex wizard state with useReducer
function wizardReducer(state, action) {
  switch (action.type) {
    case "NEXT_STEP":
      return { ...state, step: state.step + 1 };
    case "PREV_STEP":
      return { ...state, step: state.step - 1 };
    case "SET_FIELD":
      return {
        ...state,
        data: { ...state.data, [action.field]: action.value },
      };
    case "RESET":
      return initialState;
    default:
      return state;
  }
}
CriteriauseStateuseReducer
ComplexitySimple valuesMultiple sub-values
Logic locationCo-locatedExtracted, testable
DependenciesIndependentLinked transitions
BoilerplateMinimalMore code, more predictable

useContext: Built-in but Limited

useContext solves prop drilling but has performance limitations. Any context value change causes all consumers to re-render, regardless of whether they consume the changed portion.

// Problem: theme changes cause AuthConsumer to re-render
function App() {
  const [theme, setTheme] = useState("light");
  const [user, setUser] = useState(null);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <AuthContext.Provider value={{ user, setUser }}>
        <Layout />
      </AuthContext.Provider>
    </ThemeContext.Provider>
  );
}

The fix is to split contexts by change frequency and use useMemo on the value:

const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
const authValue = useMemo(() => ({ user, setUser }), [user]);

For global state that changes infrequently (theme, locale, auth status), useContext is adequate. For high-frequency updates, specialized libraries offer better performance.


Zustand: Minimal Global State

Zustand has become the leading lightweight global state library. Its API is minimal by design: create a store, access state via a hook, and mutate directly.

import { create } from "zustand";

const useCartStore = create((set, get) => ({
  items: [],
  total: 0,
  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
      total: state.total + item.price,
    })),
  removeItem: (id) =>
    set((state) => {
      const item = state.items.find((i) => i.id === id);
      return {
        items: state.items.filter((i) => i.id !== id),
        total: state.total - (item?.price || 0),
      };
    }),
}));

function CartSummary() {
  // Component only re-renders when 'total' changes
  const total = useCartStore((state) => state.total);
  return <div>Total: ${total}</div>;
}

Key advantages: no provider wrapping, automatic render optimization via selector equality, simple middleware (persist, devtools, immer), TypeScript-first design, and a ~1KB bundle size. Selectors ensure components only re-render on the slices they access.


Jotai: Atomic State Management

Jotai takes a different approach by managing state at the atom level. Each piece of state is an independent, lazy-evaluated atom that can be composed.

import { atom, useAtom } from "jotai";

const searchQueryAtom = atom("");
const debouncedQueryAtom = atom((get) => {
  const query = get(searchQueryAtom);
  return query; // In practice, add debounce logic here
});
const resultsAtom = atom(async (get) => {
  const query = get(debouncedQueryAtom);
  if (!query) return [];
  const res = await fetch(`/api/search?q=${query}`);
  return res.json();
});

function Search() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  const [results] = useAtom(resultsAtom);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultsList results={results} />
    </div>
  );
}

Benefits include granular re-renders (only atoms that change trigger updates), lazy evaluation (atoms are only computed when consumed), no global provider for basic cases, and tree-shakable architecture. Jotai excels in concurrent rendering scenarios because each atom can be suspended independently via Suspense.


TanStack Query: Server State

TanStack Query (formerly React Query) addresses server state: data fetched from and synchronized with a backend. Server state is fundamentally different from UI state — it exists primarily on the server and fetching it is an async operation with inherent latency.

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

function ProductPage({ id }) {
  const queryClient = useQueryClient();

  const { data, isLoading, error } = useQuery({
    queryKey: ["product", id],
    queryFn: () => fetch(`/api/products/${id}`).then((r) => r.json()),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });

  const mutation = useMutation({
    mutationFn: (updated) =>
      fetch(`/api/products/${id}`, {
        method: "PATCH",
        body: JSON.stringify(updated),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["product", id] });
    },
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorState />;

  return <ProductDetail product={data} onUpdate={mutation.mutate} />;
}

Server state should never be duplicated in a global UI store. TanStack Query handles cache invalidation, request deduplication, background refetching, pagination, infinite scroll, and optimistic updates with rollback.


Decision Framework

State CategoryExamplesRecommended Tool
Component-localForm inputs, toggles, countersuseState / useReducer
Global UIModals, sidebar, theme, localeZustand / Jotai
Server stateAPI data, database resultsTanStack Query
URL stateRoute params, search paramsRouter primitives

Performance Patterns

Render isolation is the most important consideration. Libraries like Zustand (selectors) and Jotai (atoms) minimize re-render scope by design. Never put rapidly changing state near stable state in context — split them into separate providers.

// Efficient: narrow selector, stable reference
const isDark = useThemeStore((s) => s.isDark);

// Inefficient: full object, unstable reference
const { isDark, toggleTheme } = useThemeStore();

React 18’s automatic batching means multiple state updates in the same event handler produce a single re-render. Use useMemo and useCallback strategically — not as a blanket rule — to prevent unnecessary child re-renders when passing objects and functions as props.


Migration Strategies

When migrating from a monolithic store like Redux, use a side-by-side approach:

  1. Migrate server state first (Redux → TanStack Query for API data). This typically removes the largest slice from your Redux store.
  2. Replace global UI state incrementally with small Zustand stores alongside existing Redux.
  3. Use an adapter layer during transition so both stores can coexist.
  4. Validate each module independently before full cutover.
  5. Measure bundle size and render performance at each stage using React DevTools Profiler.

Conclusion

In 2024, there is no single best state management tool. The correct choice depends on the category of state. Use useState for component-local concerns, Zustand or Jotai for global UI state, TanStack Query for server data, and router primitives for URL state. Combining the right tool for each category produces applications that are performant, maintainable, and straightforward to reason about.