Web Engineering ·

State Management in React 2026: Server State, Client State, and When You Need Neither

React state has fragmented into five distinct categories. Conflating them is where complexity comes from. Here is a taxonomy, the right tools for each, and a decision framework for choosing none of them.

State Management in React 2026: Server State, Client State, and When You Need Neither

Most React applications are not complicated because the product is complicated. They are complicated because someone put server data, UI flags, URL parameters, form inputs, and computed values into the same state bucket and then fought the resulting mess for two years.

State management in React has fragmented. That fragmentation is not a problem. It is the answer. Each category of state has a different shape, a different lifetime, and a different set of tools that handle it correctly. Conflating them is the root cause of most state-related complexity.

This article maps the taxonomy, covers the right tool for each category, and then explains when the right answer is to use no client state at all.


The Taxonomy

Before picking tools, name what you are dealing with:

  • Server state: data that lives on the server, is fetched asynchronously, and can change underneath you (user profile, order history, product catalog).
  • Client state: transient UI state that lives only in the browser and has no server representation (sidebar open/closed, selected tab, modal visibility).
  • URL state: state that belongs in the URL because the user should be able to bookmark or share it (search query, filters, pagination, selected item ID).
  • Form state: the in-progress value of a form before it has been submitted and validated.
  • Derived state: values computed from other state. This is not real state. It is a function of state.

The reason to name these is that each one breaks in a different way when you use the wrong tool. Server state stored in useState goes stale and does not revalidate. Client state stored in a query cache gets invalidated unnecessarily. Form state stored in a global store causes re-renders across the tree on every keystroke. Derived state stored in a useState gets out of sync.


Server State: TanStack Query and SWR

Server state is the category most applications get wrong first. The naive approach is useEffect plus useState:

// The pattern that causes problems at scale
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <Profile user={user!} />;
}

This breaks in production because it does not deduplicate concurrent requests, does not cache across component mounts, does not refetch when the window regains focus, and has no invalidation mechanism. Two instances of UserProfile mounted simultaneously will fire two identical requests.

TanStack Query handles all of this:

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

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUser(userId),
    staleTime: 1000 * 60 * 5, // 5 minutes before background refetch
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <Profile user={user!} />;
}

The queryKey is the cache key. Any two components using ["user", userId] with the same userId will share a single in-flight request and a single cache entry. Mount one thousand components that all need the same user and you get one fetch.

Optimistic Updates

Optimistic updates are where TanStack Query earns its keep. The pattern: apply the expected result immediately, roll back on failure.

function useUpdateUsername() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newUsername: string) => updateUsername(newUsername),
    onMutate: async (newUsername) => {
      // Cancel in-flight queries for this key so they do not overwrite the optimistic update
      await queryClient.cancelQueries({ queryKey: ["currentUser"] });

      const previous = queryClient.getQueryData<User>(["currentUser"]);

      queryClient.setQueryData<User>(["currentUser"], (old) =>
        old ? { ...old, username: newUsername } : old
      );

      return { previous };
    },
    onError: (_err, _newUsername, context) => {
      // Roll back
      queryClient.setQueryData(["currentUser"], context?.previous);
    },
    onSettled: () => {
      // Always refetch to confirm server state
      queryClient.invalidateQueries({ queryKey: ["currentUser"] });
    },
  });
}

SWR from Vercel is a lighter alternative with a simpler API. It covers the 80% case with less configuration:

import useSWR, { useSWRConfig } from "swr";

function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher, {
    revalidateOnFocus: true,
    dedupingInterval: 2000,
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <Profile user={data} />;
}

The difference between the two comes down to complexity requirements. TanStack Query gives you fine-grained control over cache behavior, pagination, infinite scroll, dependent queries, and background synchronization. SWR is simpler to configure and sufficient for most CRUD interfaces.

DimensionTanStack QuerySWR
Bundle size~13kB gzip~4kB gzip
DevtoolsYes (separate package)No
Optimistic updatesFull rollback supportManual
Infinite scrollBuilt-in useInfiniteQueryManual with useSWRInfinite
Dependent queriesenabled optionnull key convention
Mutation lifecycleonMutate / onError / onSettledManual
Configuration granularityHighLow

Client State: Zustand, Jotai, and useState

Client state is simpler than server state, but it is still easy to over-engineer. The first question to ask is whether you need anything beyond useState at all.

useState is the right tool when:

  • State is local to one component or a small subtree.
  • No sibling component needs to read or write it.
  • The state does not need to survive component unmount.

A modal’s open/closed flag is local state. A selected tab in a settings page is local state. A tooltip visibility is local state. Reach for a library only when you have cross-component concerns that prop drilling makes unmanageable.

When you do need shared client state, Zustand is the lowest-friction option:

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

interface NotificationStore {
  notifications: Notification[];
  addNotification: (n: Notification) => void;
  dismissNotification: (id: string) => void;
}

const useNotificationStore = create<NotificationStore>()(
  immer((set) => ({
    notifications: [],
    addNotification: (n) =>
      set((state) => {
        state.notifications.push(n);
      }),
    dismissNotification: (id) =>
      set((state) => {
        state.notifications = state.notifications.filter((n) => n.id !== id);
      }),
  }))
);

Components subscribe to slices of the store to avoid unnecessary re-renders:

// Only re-renders when notifications array changes, not on every store update
const notifications = useNotificationStore((s) => s.notifications);
const dismiss = useNotificationStore((s) => s.dismissNotification);

Jotai takes an atomic approach. Instead of one store, you compose individual atoms:

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

const sidebarOpenAtom = atom(false);
const selectedThemeAtom = atom<"light" | "dark">("light");

// Derived atom: computed, not stored
const isDarkAtom = atom((get) => get(selectedThemeAtom) === "dark");

function Sidebar() {
  const [isOpen, setIsOpen] = useAtom(sidebarOpenAtom);
  return (
    <aside className={isOpen ? "open" : "closed"}>
      <button onClick={() => setIsOpen(false)}>Close</button>
    </aside>
  );
}

Jotai’s atomic model means components only re-render when the specific atoms they subscribe to change. It also avoids the selector boilerplate of Zustand. The tradeoff is that large interdependent state graphs become harder to trace than a centralized store.

DimensionuseStateZustandJotai
SetupZeroMinimalMinimal
ScopeComponentGlobal storeAtom graph
Re-render controlManual (memo)Selector-basedAtom subscription
DevToolsNoYesYes
Async stateNoMiddlewareAsync atoms
Best forLocal UI flagsFeature-scoped storesFine-grained subscriptions

URL State: The Most Underused Pattern

URL state is state that belongs in the URL. Most applications treat it as an afterthought and then struggle to implement shareable links, bookmarking, and browser back/forward navigation.

The rule: if a user refreshes the page and they would expect the same view to appear, that state belongs in the URL.

Search query, active filters, sort order, pagination, selected item, active tab in a detail view. All of these should be URL state.

import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";

interface FilterState {
  query: string;
  status: "active" | "archived" | "all";
  page: number;
}

function useFilterState(): [FilterState, (partial: Partial<FilterState>) => void] {
  const searchParams = useSearchParams();
  const router = useRouter();

  const state: FilterState = {
    query: searchParams.get("q") ?? "",
    status: (searchParams.get("status") as FilterState["status"]) ?? "all",
    page: Number(searchParams.get("page") ?? "1"),
  };

  const setFilter = (partial: Partial<FilterState>) => {
    const params = new URLSearchParams(searchParams.toString());

    if (partial.query !== undefined) params.set("q", partial.query);
    if (partial.status !== undefined) params.set("status", partial.status);
    if (partial.page !== undefined) params.set("page", String(partial.page));

    // Reset page to 1 when filter changes
    if (partial.query !== undefined || partial.status !== undefined) {
      params.set("page", "1");
    }

    router.push(`?${params.toString()}`);
  };

  return [state, setFilter];
}

Every time the URL changes, the server component re-renders with new searchParams, and TanStack Query or SWR refetches with the new filters. No client state. No useEffect. No synchronization problem.

The nuqs library provides a typed, validated abstraction over useSearchParams that handles serialization and defaults, which is worth the dependency for complex filter surfaces.


Form State: React Hook Form and Server Actions

Form state has a unique profile: it is local to the form, short-lived, and needs validation. React Hook Form manages it without controlled inputs, which means it does not store values in React state and does not trigger re-renders on every keystroke.

import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const UpdateProfileSchema = z.object({
  displayName: z.string().min(2).max(50),
  email: z.string().email(),
  bio: z.string().max(160).optional(),
});

type UpdateProfileInput = z.infer<typeof UpdateProfileSchema>;

function UpdateProfileForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<UpdateProfileInput>({
    resolver: zodResolver(UpdateProfileSchema),
  });

  const onSubmit: SubmitHandler<UpdateProfileInput> = async (data) => {
    await updateProfile(data); // server action or API call
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("displayName")} />
      {errors.displayName && <span>{errors.displayName.message}</span>}

      <input {...register("email")} type="email" />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register("bio")} />

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

With React Server Actions, the form submission path can skip the client entirely for simple cases:

// app/profile/actions.ts
"use server";

import { z } from "zod";

const UpdateProfileSchema = z.object({
  displayName: z.string().min(2).max(50),
  email: z.string().email(),
});

export async function updateProfileAction(formData: FormData) {
  const result = UpdateProfileSchema.safeParse({
    displayName: formData.get("displayName"),
    email: formData.get("email"),
  });

  if (!result.success) {
    return { error: result.error.flatten() };
  }

  await db.user.update({ data: result.data });
  return { success: true };
}
// app/profile/page.tsx (Server Component)
import { updateProfileAction } from "./actions";

export default function ProfilePage() {
  return (
    <form action={updateProfileAction}>
      <input name="displayName" />
      <input name="email" type="email" />
      <button type="submit">Save</button>
    </form>
  );
}

For forms with complex validation feedback, progressive enhancement, or optimistic updates, reach for React Hook Form. For simple forms in Server Component trees, a Server Action with useFormState (now useActionState in React 19) handles the full cycle.


Derived State: Stop Storing It

Derived state is not state. It is a function.

// Wrong: storing derived state
const [items, setItems] = useState<Item[]>([]);
const [filteredItems, setFilteredItems] = useState<Item[]>([]);

useEffect(() => {
  setFilteredItems(items.filter((i) => i.active));
}, [items]);

// Right: compute it
const [items, setItems] = useState<Item[]>([]);
const filteredItems = items.filter((i) => i.active);

If the computation is expensive, wrap it in useMemo. Do not synchronize state with state. Synchronization bugs are some of the hardest to debug because both values look correct in isolation.


The No-State Approach: React Server Components

The most underappreciated shift in React since hooks is that Server Components eliminate the need for client state management for a large class of problems.

A component that fetches its own data, renders server-side, and accepts URL parameters as props needs no client state at all:

// app/orders/page.tsx
interface OrdersPageProps {
  searchParams: { status?: string; page?: string };
}

export default async function OrdersPage({ searchParams }: OrdersPageProps) {
  const status = searchParams.status ?? "all";
  const page = Number(searchParams.page ?? "1");

  // Direct database or API call. No useEffect. No loading state. No cache invalidation.
  const orders = await db.order.findMany({
    where: status !== "all" ? { status } : {},
    skip: (page - 1) * 20,
    take: 20,
  });

  return (
    <div>
      <StatusFilter current={status} />
      <OrderList orders={orders} />
      <Pagination current={page} />
    </div>
  );
}

The page fetches fresh on every navigation. URL state drives the query. No client state library. No loading spinner (Suspense handles it). No cache synchronization.

This does not replace TanStack Query for interactive UIs that mutate data and need optimistic updates. It replaces the pattern of fetching data in a client component that does nothing interactive.


Decision Framework

Pick your state management approach by answering four questions in order:

Is this data from the server? Yes: use TanStack Query or SWR. If it is read-only and in a Server Component, fetch directly and skip both.

Does the user need to bookmark or share this view? Yes: use URL state. Do not duplicate it into client state.

Is this form input that has not been submitted? Yes: use React Hook Form with a Zod resolver. If the form is simple and in a Server Component tree, use a Server Action directly.

Is this UI-only state that a single component owns? Yes: use useState. If it needs to be shared across siblings without prop drilling, use Zustand or Jotai.

If the answer to all four is no, you are probably dealing with derived state. Compute it.

State typeRight toolWrong tool
Server / async dataTanStack Query, SWR, direct fetch (RSC)useState + useEffect
Shareable view stateURL params (nuqs, useSearchParams)useState
Form inputReact Hook Form, useActionStateuseState per field
Shared UI stateZustand, JotaiContext with re-render risk
Local UI flagsuseStateGlobal store
Computed valuesInline expression, useMemouseState + useEffect sync

Production Considerations

A few things that break only at scale:

Stale closures in mutation callbacks. When onMutate in TanStack Query captures a closure over query data, ensure you call queryClient.cancelQueries before reading the snapshot. Otherwise a racing background refetch overwrites your optimistic state.

URL state and SSR hydration mismatch. Reading searchParams in a Client Component during SSR can produce a mismatch if the server-rendered output differs from the hydrated output. Use nuqs with { shallow: true } for client-only URL state that does not trigger server re-renders.

Zustand store resets between test cases. Zustand stores persist across renders in the same process. In Vitest and Jest, call useStore.setState(initialState) in beforeEach to prevent state leakage between test cases.

Atom hydration in Jotai with SSR. jotai/utils provides useHydrateAtoms for initializing atom values from server-rendered props. Without it, the client initializes atoms to defaults and then the UI flickers.

React 19’s use hook. In React 19, use(promise) inside a Client Component with Suspense wrapping is a valid alternative to TanStack Query for simple read cases. It does not provide caching or deduplication, so it is not a replacement, but it is the right tool for one-off async reads in Client Components that are already wrapped in Suspense.


Five categories of state. Five different tools with different tradeoffs. The productivity gain comes not from picking one library and forcing everything through it, but from matching the tool to the shape of the problem. Most applications only need three of these categories. Some only need two.

If you are starting a greenfield Next.js application in 2026: put data fetching in Server Components with direct DB calls, put filters in the URL, put form state in React Hook Form with Server Actions, and reach for TanStack Query only when you need interactive mutations or real-time revalidation. Add Zustand when you have UI state that genuinely crosses component boundaries.

That is the whole framework.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.