Skip to content

Kitchen sink of state management

reactstate-management

State management is the most over-debated, under-understood topic in the React ecosystem. Every year a new library promises to "fix" it, and every year teams over-engineer the problem. After shipping production apps for the better part of a decade, I've landed on a principle that guides every decision: match the tool to the category of state, not the other way around.

This article walks through every state management approach I reach for on real projects—local, shared, server, URL, and form state—with runnable examples and the rationale behind each choice.

The mental model: state is not one thing

The single biggest mistake I see teams make is treating all state as the same problem. It's not. State breaks down into distinct categories, and each one has a natural home:

CategoryDescriptionNatural tool
LocalUI state scoped to one componentuseState, useReducer
SharedClient state needed across a subtree or the whole appZustand, Jotai, Context
ServerData that lives on a backend and needs caching/syncTanStack Query, SWR
URLState that should survive a page refresh or be shareableSearch params, nuqs
FormUser input with validation and submission lifecycleReact Hook Form, Conform

Get the category right, and the library choice becomes obvious. Get it wrong, and you end up stuffing server responses into Redux—a pattern that has caused more accidental complexity than any other in the React ecosystem.


1. useState — the one you already know (but underuse)

Before reaching for anything external, ask: does this state leave this component? If not, useState is the answer. No context, no store, no provider.

import { useState } from "react";

function PasswordInput() {
  const [show, setShow] = useState(false);

  return (
    <div className="relative">
      <input
        type={show ? "text" : "password"}
        className="w-full rounded border px-3 py-2 pr-16"
        placeholder="Enter password"
      />
      <button
        type="button"
        onClick={() => setShow((s) => !s)}
        className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-blue-600"
      >
        {show ? "Hide" : "Show"}
      </button>
    </div>
  );
}

No library will make this simpler. A toggle, a disclosure, a hover state—these belong in useState and nowhere else. I've seen teams put UI toggles into global stores "for consistency" and then wonder why every component re-renders on every click.

useReducer for complex local state

When local state involves multiple related values or transitions that depend on the previous state, useReducer is cleaner than multiple useState calls:

import { useReducer } from "react";

type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

type FetchAction<T> =
  | { type: "FETCH" }
  | { type: "SUCCESS"; data: T }
  | { type: "ERROR"; error: string }
  | { type: "RESET" };

function fetchReducer<T>(
  state: FetchState<T>,
  action: FetchAction<T>
): FetchState<T> {
  switch (action.type) {
    case "FETCH":
      return { status: "loading" };
    case "SUCCESS":
      return { status: "success", data: action.data };
    case "ERROR":
      return { status: "error", error: action.error };
    case "RESET":
      return { status: "idle" };
  }
}

function UserProfile({ userId }: { userId: string }) {
  const [state, dispatch] = useReducer(
    fetchReducer<{ name: string; email: string }>,
    { status: "idle" }
  );

  const load = async () => {
    dispatch({ type: "FETCH" });
    try {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error("Failed to fetch");
      const data = await res.json();
      dispatch({ type: "SUCCESS", data });
    } catch (e) {
      dispatch({
        type: "ERROR",
        error: e instanceof Error ? e.message : "Unknown error",
      });
    }
  };

  if (state.status === "idle") return <button onClick={load}>Load profile</button>;
  if (state.status === "loading") return <p>Loading…</p>;
  if (state.status === "error") return <p>Error: {state.error}</p>;

  return (
    <div>
      <h2>{state.data.name}</h2>
      <p>{state.data.email}</p>
    </div>
  );
}

The discriminated union on FetchState is doing the heavy lifting here. TypeScript narrows the type at each branch, making it impossible to access data when the status is "error". This is the pattern I use before reaching for TanStack Query—when the fetch is a one-off, fire-and-forget action that doesn't need caching or deduplication.


2. Context API — the most misused tool in React

Context is a dependency injection mechanism. It was never designed to be a state manager, and using it as one leads to the re-render problem everyone complains about: any update to context value re-renders every consumer, regardless of whether the specific value they read changed.

That said, Context is the right tool for values that change infrequently and are needed deep in the tree:

import {
  createContext,
  useContext,
  useState,
  useCallback,
  useMemo,
} from "react";

type Theme = "light" | "dark";

interface ThemeContextValue {
  theme: Theme;
  toggle: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
  return ctx;
}

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  const toggle = useCallback(
    () => setTheme((t) => (t === "light" ? "dark" : "light")),
    []
  );

  const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

// Consumer — only re-renders when theme actually changes
function ThemeIndicator() {
  const { theme, toggle } = useTheme();

  return (
    <button onClick={toggle}>
      Current theme: {theme}
    </button>
  );
}

The useMemo on the value object is critical. Without it, every render of ThemeProvider creates a new object reference, and every consumer re-renders even if theme hasn't changed.

When I use Context: themes, locale, auth session, feature flags—values that change maybe once per session.

When I don't: anything that changes on user interaction (typing, scrolling, filtering). That's where external stores earn their keep.


3. Zustand — the pragmatic default for shared client state

Zustand is the library I reach for first when state needs to be shared across components that aren't in a direct parent-child relationship. The API surface is tiny, it works outside of React (useful for tests and middleware), and it handles the selector-based re-render optimization that Context can't.

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { devtools } from "zustand/middleware";

interface Notification {
  id: string;
  message: string;
  type: "info" | "success" | "error";
}

interface NotificationStore {
  notifications: Notification[];
  add: (message: string, type: Notification["type"]) => void;
  dismiss: (id: string) => void;
  clearAll: () => void;
}

const useNotificationStore = create<NotificationStore>()(
  devtools(
    immer((set) => ({
      notifications: [],

      add: (message, type) =>
        set((state) => {
          state.notifications.push({
            id: crypto.randomUUID(),
            message,
            type,
          });
        }),

      dismiss: (id) =>
        set((state) => {
          state.notifications = state.notifications.filter((n) => n.id !== id);
        }),

      clearAll: () =>
        set((state) => {
          state.notifications = [];
        }),
    })),
    { name: "notifications" }
  )
);

// Component that reads notifications — re-renders only when the array changes
function NotificationList() {
  const notifications = useNotificationStore((s) => s.notifications);
  const dismiss = useNotificationStore((s) => s.dismiss);

  return (
    <ul className="fixed right-4 top-4 z-50 space-y-2">
      {notifications.map((n) => (
        <li
          key={n.id}
          className={`rounded px-4 py-2 text-white ${
            n.type === "error"
              ? "bg-red-600"
              : n.type === "success"
                ? "bg-green-600"
                : "bg-blue-600"
          }`}
        >
          {n.message}
          <button onClick={() => dismiss(n.id)} className="ml-3 font-bold">
            ×
          </button>
        </li>
      ))}
    </ul>
  );
}

// Component that triggers notifications — never re-renders from store changes
function SomeAction() {
  const add = useNotificationStore((s) => s.add);

  return (
    <button onClick={() => add("File uploaded", "success")}>
      Upload
    </button>
  );
}

Notice how SomeAction selects only the add function. Because functions are referentially stable in Zustand (they're defined once in create), this component never re-renders when notifications change. That's the selector pattern paying dividends.

Zustand slices for larger stores

For apps that grow, I split the store into slices:

import { create } from "zustand";

interface AuthSlice {
  user: { id: string; name: string } | null;
  login: (user: { id: string; name: string }) => void;
  logout: () => void;
}

interface UISlice {
  sidebarOpen: boolean;
  toggleSidebar: () => void;
}

type AppStore = AuthSlice & UISlice;

const createAuthSlice = (
  set: (partial: Partial<AppStore> | ((state: AppStore) => Partial<AppStore>)) => void
): AuthSlice => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null }),
});

const createUISlice = (
  set: (partial: Partial<AppStore> | ((state: AppStore) => Partial<AppStore>)) => void
): UISlice => ({
  sidebarOpen: false,
  toggleSidebar: () =>
    set((state) => ({ sidebarOpen: !state.sidebarOpen })),
});

const useAppStore = create<AppStore>()((set) => ({
  ...createAuthSlice(set),
  ...createUISlice(set),
}));

// Usage — each component subscribes only to what it needs
function Sidebar() {
  const open = useAppStore((s) => s.sidebarOpen);
  if (!open) return null;
  return <nav className="w-64 border-r p-4">Sidebar content</nav>;
}

4. Jotai — atomic state for fine-grained reactivity

Jotai takes the opposite approach to Zustand. Instead of a single store, you create individual atoms—primitive units of state—and compose them. This shines when you have lots of independent pieces of state where a single store would cause unnecessary coupling.

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";

// Primitive atoms
const fontSizeAtom = atomWithStorage("fontSize", 16);
const fontFamilyAtom = atomWithStorage("fontFamily", "Inter");
const lineHeightAtom = atomWithStorage("lineHeight", 1.6);

// Derived atom — recomputes when any dependency changes
const cssVariablesAtom = atom((get) => ({
  "--font-size": `${get(fontSizeAtom)}px`,
  "--font-family": get(fontFamilyAtom),
  "--line-height": `${get(lineHeightAtom)}`,
}));

// Write-only atom for batch operations
const resetTypographyAtom = atom(null, (_get, set) => {
  set(fontSizeAtom, 16);
  set(fontFamilyAtom, "Inter");
  set(lineHeightAtom, 1.6);
});

function FontSizeControl() {
  const [size, setSize] = useAtom(fontSizeAtom);

  return (
    <label className="flex items-center gap-3">
      Font size: {size}px
      <input
        type="range"
        min={12}
        max={24}
        value={size}
        onChange={(e) => setSize(Number(e.target.value))}
      />
    </label>
  );
}

function FontFamilyControl() {
  const [family, setFamily] = useAtom(fontFamilyAtom);

  return (
    <label className="flex items-center gap-3">
      Font family:
      <select value={family} onChange={(e) => setFamily(e.target.value)}>
        <option value="Inter">Inter</option>
        <option value="Georgia">Georgia</option>
        <option value="Menlo">Menlo</option>
      </select>
    </label>
  );
}

function ResetButton() {
  const reset = useSetAtom(resetTypographyAtom);
  return <button onClick={reset}>Reset to defaults</button>;
}

function Preview() {
  const vars = useAtomValue(cssVariablesAtom);

  return (
    <div style={vars as React.CSSProperties}>
      <p style={{ fontSize: "var(--font-size)", fontFamily: "var(--font-family)", lineHeight: "var(--line-height)" }}>
        The quick brown fox jumps over the lazy dog.
      </p>
    </div>
  );
}

atomWithStorage persists to localStorage automatically. Preview only re-renders when the computed CSS variables object changes. FontSizeControl doesn't re-render when fontFamily changes, and vice versa. That's the atom model—surgical reactivity without selectors.

When I pick Jotai over Zustand: when the state is a collection of independent knobs (settings panels, builder UIs, canvas editors) rather than a cohesive domain model.


5. TanStack Query — server state is a different problem entirely

This is the most important section of this article. The single best refactor I've done on legacy codebases is ripping server data out of client state stores and into TanStack Query. Server state has fundamentally different concerns: caching, background refetching, deduplication, optimistic updates, pagination, stale-while-revalidate. No client state library handles these well, because they weren't designed to.

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

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

// Query key factory — keeps keys consistent and type-safe
const todoKeys = {
  all: ["todos"] as const,
  lists: () => [...todoKeys.all, "list"] as const,
  list: (filters: { status?: string }) =>
    [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, "detail"] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
};

// API layer — plain functions, no React
async function fetchTodos(status?: string): Promise<Todo[]> {
  const params = status ? `?completed=${status === "completed"}` : "";
  const res = await fetch(`/api/todos${params}`);
  if (!res.ok) throw new Error("Failed to fetch todos");
  return res.json();
}

async function toggleTodo(todo: Todo): Promise<Todo> {
  const res = await fetch(`/api/todos/${todo.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ completed: !todo.completed }),
  });
  if (!res.ok) throw new Error("Failed to update todo");
  return res.json();
}

// Custom hook with query key factory
function useTodos(status?: string) {
  return useQuery({
    queryKey: todoKeys.list({ status }),
    queryFn: () => fetchTodos(status),
    staleTime: 30_000, // consider fresh for 30s
  });
}

// Mutation with optimistic update
function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: toggleTodo,

    onMutate: async (todo) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: todoKeys.lists() });

      // Snapshot previous value
      const previous = queryClient.getQueryData<Todo[]>(
        todoKeys.list({})
      );

      // Optimistically update
      queryClient.setQueryData<Todo[]>(todoKeys.list({}), (old) =>
        old?.map((t) =>
          t.id === todo.id ? { ...t, completed: !t.completed } : t
        )
      );

      return { previous };
    },

    onError: (_err, _todo, context) => {
      // Roll back on error
      if (context?.previous) {
        queryClient.setQueryData(todoKeys.list({}), context.previous);
      }
    },

    onSettled: () => {
      // Refetch to ensure consistency
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
}

function TodoList() {
  const { data: todos, isLoading, error } = useTodos();
  const toggleMutation = useToggleTodo();

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {todos?.map((todo) => (
        <li key={todo.id} className="flex items-center gap-2">
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleMutation.mutate(todo)}
          />
          <span className={todo.completed ? "line-through" : ""}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

The query key factory (todoKeys) is a pattern I use on every project. It gives you type-safe, hierarchical keys so you can invalidate at any granularity—all todos, all lists, a specific filtered list, or a single todo detail.

The optimistic update pattern here is production-grade: snapshot, update, roll back on error, refetch on settle. This gives the user instant feedback while guaranteeing eventual consistency with the server.


6. URL state — the forgotten state manager

URL search params are the most underrated state management tool. They give you deep linking, shareability, browser history integration, and server-side access—for free. Any state that represents a user's current "view" of data belongs in the URL.

import { useSearchParams } from "next/navigation";
import { useCallback } from "react";

type SortField = "name" | "date" | "price";
type SortOrder = "asc" | "desc";

function useTableParams() {
  const searchParams = useSearchParams();

  const page = Number(searchParams.get("page")) || 1;
  const perPage = Number(searchParams.get("perPage")) || 20;
  const sort = (searchParams.get("sort") as SortField) || "name";
  const order = (searchParams.get("order") as SortOrder) || "asc";
  const search = searchParams.get("q") || "";

  const setParams = useCallback(
    (updates: Record<string, string | number | null>) => {
      const params = new URLSearchParams(searchParams.toString());

      Object.entries(updates).forEach(([key, value]) => {
        if (value === null || value === "") {
          params.delete(key);
        } else {
          params.set(key, String(value));
        }
      });

      // Reset to page 1 when filters change
      if ("q" in updates || "sort" in updates || "order" in updates) {
        params.set("page", "1");
      }

      window.history.pushState(null, "", `?${params.toString()}`);
    },
    [searchParams]
  );

  return { page, perPage, sort, order, search, setParams };
}

function ProductTable() {
  const { page, sort, order, search, setParams } = useTableParams();

  return (
    <div>
      <input
        type="search"
        defaultValue={search}
        placeholder="Search products…"
        onChange={(e) => setParams({ q: e.target.value })}
      />

      <table>
        <thead>
          <tr>
            {(["name", "date", "price"] as const).map((field) => (
              <th
                key={field}
                onClick={() =>
                  setParams({
                    sort: field,
                    order: sort === field && order === "asc" ? "desc" : "asc",
                  })
                }
                className="cursor-pointer select-none"
              >
                {field}
                {sort === field && (order === "asc" ? " ↑" : " ↓")}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>{/* rows rendered from query using page, sort, search */}</tbody>
      </table>

      <div className="flex gap-2">
        <button
          disabled={page <= 1}
          onClick={() => setParams({ page: page - 1 })}
        >
          Previous
        </button>
        <span>Page {page}</span>
        <button onClick={() => setParams({ page: page + 1 })}>
          Next
        </button>
      </div>
    </div>
  );
}

The beauty here is that a user can copy the URL ?q=wireless&sort=price&order=desc&page=3, send it to a colleague, and they land on the exact same view. No client state store can do that. And when you pair this with TanStack Query, the search params become your query key inputs—the URL drives the cache.


7. Combining everything — the real-world kitchen sink

Here's where the mental model comes together. A realistic feature uses multiple state categories simultaneously. Let's say we're building a dashboard panel:

import { create } from "zustand";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation";
import { useState } from "react";

// URL state — current filters (shareable)
function useDashboardFilters() {
  const searchParams = useSearchParams();
  return {
    dateRange: searchParams.get("range") || "7d",
    metric: searchParams.get("metric") || "revenue",
  };
}

// Server state — data from API (cached, auto-refreshed)
function useDashboardData(filters: { dateRange: string; metric: string }) {
  return useQuery({
    queryKey: ["dashboard", filters],
    queryFn: () =>
      fetch(`/api/dashboard?range=${filters.dateRange}&metric=${filters.metric}`)
        .then((r) => r.json()),
    refetchInterval: 60_000,
  });
}

// Client state — UI preferences (persisted across sessions)
const useDashboardUI = create<{
  chartType: "line" | "bar";
  setChartType: (type: "line" | "bar") => void;
}>((set) => ({
  chartType: "line",
  setChartType: (chartType) => set({ chartType }),
}));

// Local state — ephemeral interactions
function Dashboard() {
  const filters = useDashboardFilters();                   // URL
  const { data, isLoading } = useDashboardData(filters);   // Server
  const chartType = useDashboardUI((s) => s.chartType);    // Shared client
  const [hoveredPoint, setHoveredPoint] = useState(null);   // Local

  if (isLoading) return <p>Loading dashboard…</p>;

  return (
    <div>
      <FilterBar />
      <ChartTypeToggle />
      <Chart
        data={data}
        type={chartType}
        onHover={setHoveredPoint}
      />
      {hoveredPoint && <Tooltip point={hoveredPoint} />}
    </div>
  );
}

Four categories of state, four tools, zero overlap. The URL holds what's shareable. TanStack Query handles the server data. Zustand persists the UI preference. useState tracks the hover. Each tool does exactly what it was designed to do.


The decision framework

When you need state in a React app, run through this:

  1. Does it leave this component? No → useState / useReducer
  2. Is it data from a server? Yes → TanStack Query (or SWR)
  3. Should it survive a page refresh or be shareable? Yes → URL search params
  4. Is it form input with validation? Yes → React Hook Form / Conform
  5. Is it shared client state (UI preferences, selections, toggles)? → Zustand for cohesive domains, Jotai for independent atoms
  6. Is it a rarely-changing value needed deep in the tree? → Context

The order matters. Most state falls into categories 1–3. If you handle server state and local state properly, the amount of shared client state you actually need in a Zustand or Jotai store is surprisingly small—often just UI preferences and cross-component selections.

What about Redux?

Redux Toolkit is a fine library. If your team already uses it and has established patterns, there's no reason to migrate for the sake of it. But for new projects, I haven't reached for Redux in years. Zustand covers the same use cases with less boilerplate and no provider requirement. The Redux DevTools middleware for Zustand gives you the same debugging experience.

The one case where Redux still has an edge is when you need saga-like complex async orchestration or when your team benefits from the strict, opinionated structure that Redux enforces. If "convention over configuration" matters more to your team than API simplicity, Redux Toolkit is a reasonable choice.

Closing thoughts

The "kitchen sink" approach isn't about using every library at once for the sake of it. It's about recognizing that state is not monolithic, and the right tool for each category makes the whole system simpler. A codebase that uses useState + TanStack Query + URL params for 90% of its state and Zustand for the remaining 10% is easier to reason about than one that funnels everything through a single global store.

The best state management is the least state management. Derive what you can. Push to the URL what you should. Cache server data properly. And keep everything else local until you have a concrete reason not to.