Modern React State Architecture: TanStack Query (Server State) vs. Zustand (Client State)

Storing remote server data in client-side global state stores leads to synchronization anomalies, cache invalidation nightmares, and bloated reducers. Master the separation of asynchronous server cache from ephemeral UI state.

The Great State Conflation: Server Cache vs. Client UI

For nearly a decade, React applications treated all data identically: fetch an array of records from a REST endpoint, dispatch an action, and store the array in a single monolithic global store (such as Redux). As applications grew, this pattern created massive complexity: loading spinners entangled with business logic, stale data bugs when another browser tab modified a record, and thousands of lines of reducer boilerplate just to perform simple CRUD operations.

The root problem was a failure to recognize that applications manage two completely distinct types of state:

  • Server State: Data that is owned by the remote database (user accounts, billing histories, article lists). It is asynchronous, can be changed remotely without your application's knowledge, and requires caching, background revalidation, and optimistic updates.
  • Client State: Ephemeral UI state that is purely local to the browser session (modal open/closed flags, active sidebar tabs, form draft inputs, dark mode toggles). It is synchronous, reliable, and completely controlled by the client.

1. Eliminating Boilerplate with TanStack Query v5

TanStack Query (formerly React Query) treats server state not as global variables, but as an asynchronous distributed cache. It handles automatic background refetching on window focus, request deduplication, garbage collection of unused endpoints, and structural sharing out of the box:

// src/features/projects/api/useProjects.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { Project } from "../types";

export const projectKeys = {
  all: ["projects"] as const,
  lists: () => [...projectKeys.all, "list"] as const,
  list: (filters: { status?: string }) => [...projectKeys.lists(), filters] as const,
  details: () => [...projectKeys.all, "detail"] as const,
  detail: (id: string) => [...projectKeys.details(), id] as const,
};

// Strongly-typed query hook
export function useProjects(status?: string) {
  return useQuery({
    queryKey: projectKeys.list({ status }),
    queryFn: async (): Promise => {
      const params = new URLSearchParams(status ? { status } : {});
      const res = await fetch(`/api/projects/?${params}`);
      if (!res.ok) throw new Error("Failed to load projects");
      return res.json();
    },
    staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes
    gcTime: 1000 * 60 * 30,    // Keep unused cache in memory for 30 minutes
  });
}

2. Streamlined Client State with Zustand

For state that genuinely belongs to the client session—such as selected table rows, active filters, or UI theme configuration—Zustand provides a minimal, hook-based store without Context re-render penalties or boilerplate:

// src/stores/useUiStore.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

interface UiState {
  sidebarOpen: boolean;
  selectedProjectIds: Set;
  theme: "dark" | "light";
  toggleSidebar: () => void;
  selectProject: (id: string) => void;
  deselectProject: (id: string) => void;
  clearSelection: () => void;
  setTheme: (theme: "dark" | "light") => void;
}

export const useUiStore = create()(
  persist(
    (set) => ({
      sidebarOpen: true,
      selectedProjectIds: new Set(),
      theme: "dark",
      toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
      selectProject: (id) =>
        set((s) => ({
          selectedProjectIds: new Set(s.selectedProjectIds).add(id),
        })),
      deselectProject: (id) =>
        set((s) => {
          const next = new Set(s.selectedProjectIds);
          next.delete(id);
          return { selectedProjectIds: next };
        }),
      clearSelection: () => set({ selectedProjectIds: new Set() }),
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "devmanue-ui-storage",
      storage: createJSONStorage(() => localStorage),
      // Only persist theme and sidebar preference, not transient table selections
      partialize: (state) => ({
        sidebarOpen: state.sidebarOpen,
        theme: state.theme,
      }),
    }
  )
);

3. Optimistic Mutations: Zero Perceived Latency

Modern applications should not display a loading spinner for routine operations like starring an item or updating a status. With TanStack Query, you update the local cache immediately, apply the change visually, and automatically roll back if the network call fails:

// src/features/projects/api/useUpdateProjectStatus.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { projectKeys } from "./useProjects";
import type { Project } from "../types";

export function useUpdateProjectStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, status }: { id: string; status: Project["status"] }) => {
      const res = await fetch(`/api/projects/${id}/status/`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ status }),
      });
      if (!res.ok) throw new Error("Status update failed");
      return res.json();
    },

    // When mutation is called:
    onMutate: async ({ id, status }) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: projectKeys.all });

      // Snapshot previous value for rollback
      const previousProjects = queryClient.getQueryData(projectKeys.lists());

      // Optimistically update the cache
      queryClient.setQueriesData({ queryKey: projectKeys.lists() }, (old) => {
        if (!old) return [];
        return old.map((p) => (p.id === id ? { ...p, status } : p));
      });

      return { previousProjects };
    },

    // If mutation fails, restore snapshot
    onError: (err, variables, context) => {
      if (context?.previousProjects) {
        queryClient.setQueriesData({ queryKey: projectKeys.lists() }, context.previousProjects);
      }
    },

    // Always invalidate after error or success to synchronize with backend reality
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
    },
  });
}

4. Architectural Comparison

Dimension Legacy Monolithic Redux Modern Dual-Tier Architecture
Server State Manual actions, reducers, and loading flags TanStack Query (declarative caching, deduplication)
Client UI State Merged into the same global store Zustand (atomic, localized hooks)
Boilerplate Extremely high (types, actions, reducers, selectors) Minimal (plain async functions + atomic stores)
Stale Data Defense Manual polling or outdated views Automatic background revalidation on window focus
Component Re-renders Requires complex memoized selectors Automatic selective subscriptions
Architectural Continuity & Deep Dives

For related production architectures and system implementations, explore these companion guides:

Key Architectural Takeaways

  • Draw the Boundary: Keep remote server data out of client state managers; use TanStack Query as an asynchronous cache layer.
  • Keep Client Stores Lean: Use Zustand strictly for interactive, synchronous UI variables (dialogs, themes, draft inputs).
  • Delight Users with Optimism: Leverage onMutate rollback snapshots to make updates feel instantaneous while preserving backend data consistency.
All Insights
Chat on WhatsApp