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