Skip to content

React — State Management

OWNER: floris, floor ALSO_USED_BY: alexander, tobias LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: React 19.x


Overview

GE convention: minimize client state. Most "state" is server data (TanStack Query) or URL state (nuqs). Only the remaining ~10% needs client state management.


Decision Matrix

What kind of state is it?

1. Data from an API / database?
   → TanStack Query (Section 1)

2. State that should survive page reload or be shareable via URL?
   → URL State with nuqs (Section 2)

3. State shared by 2-3 nearby components?
   → React Context (Section 3)

4. Global client state accessed by many unrelated components?
   → Zustand (Section 4)

5. Form input state?
   → React Hook Form (see wiki/docs/stack/react/forms.md)

6. Optimistic mutation state?
   → useOptimistic (Section 5)

7. Local component-only state?
   → useState / useReducer (Section 6)

Quick Reference Table

State Type Tool Example
Server/API data TanStack Query user profile, product list, dashboard metrics
URL-driven nuqs search filters, pagination, tab selection, sort order
Shared UI (small scope) React Context theme, sidebar open/closed, modal state
Global client (wide scope) Zustand shopping cart, notification queue, wizard progress
Form inputs React Hook Form login form, multi-step onboarding
Optimistic useOptimistic like button, toggle, inline edit
Component-local useState dropdown open, hover state, animation frame

1. Server State — TanStack Query

GE's primary state layer. Most data in GE apps comes from APIs.

CHECK: all API data fetching uses TanStack Query (NOT useEffect+fetch) CHECK: query keys follow [entity, ...params] convention CHECK: mutations use useMutation with onSuccess invalidation CHECK: staleTime is set per query — not left at default 0

// query-keys.ts — centralized key factory
export const queryKeys = {
  users: {
    all: ["users"] as const,
    detail: (id: string) => ["users", id] as const,
    list: (filters: UserFilters) => ["users", "list", filters] as const,
  },
  projects: {
    all: ["projects"] as const,
    detail: (id: string) => ["projects", id] as const,
  },
} as const;

// use-users.ts
export function useUsers(filters: UserFilters) {
  return useQuery({
    queryKey: queryKeys.users.list(filters),
    queryFn: () => fetchUsers(filters),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

// use-update-user.ts
export function useUpdateUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: updateUser,
    onSuccess: (_data, variables) => {
      queryClient.invalidateQueries({
        queryKey: queryKeys.users.detail(variables.id),
      });
    },
  });
}

ANTI_PATTERN: useEffect + useState + fetch for API data FIX: use TanStack Query — handles caching, dedup, refetch, error, loading

ANTI_PATTERN: queryKey as a bare string "users" FIX: use key factory object — prevents key collisions and enables targeted invalidation

ANTI_PATTERN: calling queryClient.invalidateQueries() with no key (invalidates everything) FIX: always pass the narrowest queryKey that covers the affected data

ANTI_PATTERN: staleTime: 0 on frequently-accessed queries FIX: set appropriate staleTime — 1-5 min for most data, 30s for real-time


2. URL State — nuqs

State that should persist across page loads or be shareable via link.

CHECK: search filters, pagination, sort, and tab selection live in URL CHECK: URL state uses nuqs (NOT manual useSearchParams) CHECK: URL params have sensible defaults so pages work without params

"use client";

import { useQueryState, parseAsInteger, parseAsString } from "nuqs";

export function useProductFilters() {
  const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""));
  const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
  const [sort, setSort] = useQueryState("sort", parseAsString.withDefault("newest"));

  return { search, setSearch, page, setPage, sort, setSort };
}

ANTI_PATTERN: storing filter/pagination state in useState FIX: use URL state — users can bookmark, share, and use browser back/forward

ANTI_PATTERN: manually constructing URLSearchParams and calling router.push FIX: use nuqs — handles serialization, type safety, and shallow updates


3. React Context

Shared UI state in a bounded subtree.

CHECK: context is used for UI state only (NOT server data) CHECK: context value is memoized to prevent unnecessary re-renders CHECK: provider is placed at the narrowest possible scope IF: context updates cause re-renders in 10+ components THEN: split into multiple contexts or switch to Zustand

// sidebar-context.tsx
interface SidebarContextValue {
  isOpen: boolean;
  toggle: () => void;
}

const SidebarContext = createContext<SidebarContextValue | null>(null);

export function SidebarProvider({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(true);
  const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
  const value = useMemo(() => ({ isOpen, toggle }), [isOpen, toggle]);
  return (
    <SidebarContext.Provider value={value}>
      {children}
    </SidebarContext.Provider>
  );
}

export function useSidebar(): SidebarContextValue {
  const context = useContext(SidebarContext);
  if (!context) throw new Error("useSidebar must be used within SidebarProvider");
  return context;
}

ANTI_PATTERN: single global context with many unrelated state fields FIX: split into focused contexts — one per concern

ANTI_PATTERN: context value not memoized FIX: wrap value in useMemo, callbacks in useCallback

ANTI_PATTERN: context for server data (API responses) FIX: use TanStack Query — context has no caching, dedup, or refetch


4. Zustand — Global Client State

For client state accessed by many unrelated components across the tree.

CHECK: store is created with create() from zustand CHECK: store selectors are granular — select only what the component needs CHECK: store has no API calls — keep server state in TanStack Query IF: store grows beyond 10 fields THEN: split into multiple stores by domain

// stores/cart-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  totalItems: () => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) =>
        set((state) => ({ items: [...state.items, item] })),
      removeItem: (id) =>
        set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
      clearCart: () => set({ items: [] }),
      totalItems: () => get().items.length,
    }),
    { name: "cart-storage" }
  )
);

// In component — granular selector
function CartBadge() {
  const totalItems = useCartStore((state) => state.items.length);
  return <Badge>{totalItems}</Badge>;
}

ANTI_PATTERN: selecting the entire store useCartStore((state) => state) FIX: select only needed fields — prevents re-renders on unrelated changes

ANTI_PATTERN: API fetching inside zustand actions FIX: keep fetching in TanStack Query — zustand is for client-only state

ANTI_PATTERN: zustand for state used by 1-2 components FIX: use useState or useReducer — zustand adds unnecessary indirection


5. Optimistic State — useOptimistic

Instant UI feedback while server processes the mutation.

CHECK: optimistic updates pair with Server Actions or mutations CHECK: optimistic state reverts automatically on error IF: mutation takes > 200ms and user sees a loading spinner THEN: add optimistic update for better perceived performance

"use client";

import { useOptimistic } from "react";

export function LikeButton({ postId, initialLikes, isLiked }: LikeButtonProps) {
  const [optimisticLikes, setOptimisticLikes] = useOptimistic(
    { count: initialLikes, liked: isLiked },
    (current, action: "like" | "unlike") => ({
      count: action === "like" ? current.count + 1 : current.count - 1,
      liked: action === "like",
    })
  );

  async function handleToggle() {
    const action = optimisticLikes.liked ? "unlike" : "like";
    setOptimisticLikes(action);
    await toggleLike(postId, action);
  }

  return (
    <button onClick={handleToggle}>
      {optimisticLikes.liked ? "Unlike" : "Like"} ({optimisticLikes.count})
    </button>
  );
}

6. Local State — useState / useReducer

For state that belongs to a single component.

CHECK: local state stays local — do NOT lift unless another component needs it IF: state transitions are complex (3+ related fields changing together) THEN: use useReducer instead of multiple useState calls IF: state is derived from props THEN: compute it during render — do NOT sync with useEffect

// useReducer for complex local state
type FormState = { step: number; data: Partial<FormData>; errors: string[] };
type FormAction =
  | { type: "NEXT_STEP"; data: Partial<FormData> }
  | { type: "PREV_STEP" }
  | { type: "SET_ERRORS"; errors: string[] };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (action.type) {
    case "NEXT_STEP":
      return { ...state, step: state.step + 1, data: { ...state.data, ...action.data }, errors: [] };
    case "PREV_STEP":
      return { ...state, step: Math.max(0, state.step - 1), errors: [] };
    case "SET_ERRORS":
      return { ...state, errors: action.errors };
  }
}

ANTI_PATTERN: syncing props to state with useEffect FIX: compute derived values during render or use a key to reset

ANTI_PATTERN: 5+ useState calls in one component FIX: consolidate with useReducer or extract a custom hook


GE-Specific Conventions

State Audit Rule

CHECK: before adding any state, ask: "does this already exist somewhere?" IF: the data comes from the server THEN: TanStack Query — never duplicate server data in client state IF: the state reflects a URL-visible selection THEN: nuqs — never hide navigable state in memory

Hydration Safety

CHECK: zustand stores with persist middleware use skipHydration in SSR CHECK: no hydration mismatches — client initial state matches server render IF: using zustand persist with Next.js SSR THEN: initialize with server-safe defaults, hydrate on client mount

Provider Placement

CHECK: TanStack QueryClientProvider is in root layout CHECK: zustand stores need NO provider (hook-based) CHECK: Context providers are at the narrowest scope that covers all consumers


Cross-References

READ_ALSO: wiki/docs/stack/react/index.md READ_ALSO: wiki/docs/stack/react/forms.md READ_ALSO: wiki/docs/stack/react/patterns.md READ_ALSO: wiki/docs/stack/react/pitfalls.md