lidate?: (value: string) => boolean;
}
export function useControlledInput({ initialValue = '', validate }: UseControlledInputOptions = {}) {
const [value, setValue] = useState(initialValue);
const [isValid, setIsValid] = useState(true);
const handleChange = useCallback((next: string) => {
setValue(next);
if (validate) {
setIsValid(validate(next));
}
}, [validate]);
const reset = useCallback(() => {
setValue(initialValue);
setIsValid(true);
}, [initialValue]);
return { value, setValue, isValid, handleChange, reset };
}
**Rationale:** Keeping transient state local prevents unnecessary parent re-renders. The hook encapsulates validation logic, making it reusable across forms without polluting global stores.
### Step 2: Delegate Network Data to a Cache Layer
Server state requires caching, background refetching, and explicit invalidation. TanStack Query handles these concerns natively, eliminating manual cache management.
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface CatalogItem {
id: string;
sku: string;
price: number;
inStock: boolean;
}
async function fetchInventory(filters: Record<string, string>): Promise<CatalogItem[]> {
const params = new URLSearchParams(filters).toString();
const response = await fetch(`/api/inventory?${params}`);
if (!response.ok) throw new Error('Inventory fetch failed');
return response.json();
}
export function useInventoryQuery(filters: Record<string, string>) {
return useQuery({
queryKey: ['inventory', filters],
queryFn: () => fetchInventory(filters),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
});
}
export function useUpdateStock() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: { itemId: string; quantity: number }) => {
await fetch('/api/inventory/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['inventory'] });
queryClient.invalidateQueries({ queryKey: ['item', variables.itemId] });
},
});
}
Rationale: staleTime controls how long data is considered fresh, while gcTime determines cache eviction. Invalidating specific query keys after mutations ensures UI consistency without full page reloads. This pattern removes the need to manually sync server data with global stores.
Step 3: Bind Shareable State to the URL
URL state enables deep linking, bookmarking, and shareable filters. Only persistence-worthy data should be encoded.
import { useState, useEffect, useCallback } from 'react';
const ROUTE_KEYS = ['page', 'sort', 'category', 'minPrice'] as const;
type RouteKey = (typeof ROUTE_KEYS)[number];
export function useRouteState() {
const [params, setParams] = useState<Record<RouteKey, string>>(() => {
const search = new URLSearchParams(window.location.search);
return Object.fromEntries(ROUTE_KEYS.map(k => [k, search.get(k) || ''])) as Record<RouteKey, string>;
});
const updateRoute = useCallback((key: RouteKey, value: string) => {
setParams(prev => ({ ...prev, [key]: value }));
}, []);
useEffect(() => {
const searchParams = new URLSearchParams();
(Object.entries(params) as [RouteKey, string][]).forEach(([key, val]) => {
if (val) searchParams.set(key, val);
});
const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
window.history.replaceState(null, '', newUrl);
}, [params]);
return { params, updateRoute };
}
Rationale: Using replaceState prevents history pollution during rapid filter changes. The hook synchronizes URL and memory state bidirectionally, ensuring that bookmarked links restore the exact UI configuration. Heavy or ephemeral data is excluded to avoid URL length limits and encoding overhead.
Step 4: Reserve Global Stores for Cross-Cutting Concerns
Global state should be limited to data shared across unrelated component trees, such as authentication tokens, workspace settings, or feature flags.
import { create } from 'zustand';
interface WorkspaceConfig {
theme: 'light' | 'dark';
locale: string;
notificationsEnabled: boolean;
setTheme: (theme: 'light' | 'dark') => void;
toggleNotifications: () => void;
}
export const useWorkspaceStore = create<WorkspaceConfig>((set) => ({
theme: 'light',
locale: 'en-US',
notificationsEnabled: true,
setTheme: (theme) => set({ theme }),
toggleNotifications: () => set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
}));
Rationale: Zustand provides fine-grained subscriptions, meaning components only re-render when the specific slice they consume changes. This avoids the broadcast problem inherent in Context. The store is deliberately scoped to configuration data, not UI or server state.
Architecture Flow
The data flow follows a unidirectional, layer-aware pattern:
- User interacts with a local input (
useControlledInput)
- Filter changes trigger
updateRoute in useRouteState
- URL parameters update, which are passed as dependencies to
useInventoryQuery
- TanStack Query fetches or returns cached data based on
staleTime
- UI renders using the query result, while
useWorkspaceStore supplies theme/locale context
- Mutations invalidate specific query keys, triggering background refetches
This separation ensures that UI interactions never block network requests, server data never pollutes local state, and URL synchronization remains predictable.
Pitfall Guide
1. The Context Broadcast Trap
Explanation: Using React Context for high-frequency updates (e.g., scroll position, form typing, drag coordinates) causes every consumer to re-render on each change. Context lacks built-in selector optimization.
Fix: Replace Context with Zustand or Redux Toolkit for frequently updated state. If Context must be used, split it into multiple providers (e.g., ThemeContext, AuthContext) and memoize consumers with React.memo.
2. Server-Client State Collision
Explanation: Storing API responses alongside UI flags in the same global store creates cache invalidation nightmares. Manual synchronization leads to stale data and race conditions.
Fix: Let TanStack Query own all network data. Derive loading, error, and success states from query hooks. Keep global stores strictly for client-side configuration and cross-cutting concerns.
3. URL State Over-Synchronization
Explanation: Pushing every keystroke or toggle to the URL creates noisy addresses, exceeds browser length limits, and triggers unnecessary history entries.
Fix: Only persist shareable, deterministic state (pagination, filters, search queries). Debounce URL writes using setTimeout or useEffect with dependency arrays. Use replaceState instead of pushState for filter updates.
4. Ignoring Serialization Boundaries
Explanation: Attempting to store complex objects, dates, or nested arrays directly in URL parameters breaks encoding rules and creates parsing errors.
Fix: Flatten state to primitives before URL encoding. Use base64 encoding or hash-based identifiers for complex payloads. Validate and sanitize parsed values on mount to prevent injection or type errors.
5. Cache Staleness Blind Spots
Explanation: Assuming fetched data remains accurate indefinitely leads to users seeing outdated inventory, pricing, or user profiles.
Fix: Configure staleTime based on data volatility. Implement explicit invalidation on mutations. Use background refetch intervals (refetchInterval) for real-time dashboards. Always provide optimistic UI updates with rollback on failure.
6. Testing Implementation Instead of Behavior
Explanation: Unit testing reducers or store actions in isolation without verifying UI integration misses race conditions, cache misses, and URL sync failures.
Fix: Test state transitions via component renders with mocked network layers. Verify that URL changes reflect in the UI and vice versa. Use testing libraries that support async query resolution and cache invalidation simulation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| E-commerce catalog with filters | TanStack Query + URL sync + Local state | Cache-driven rendering reduces API calls; URL enables shareable product views | Low network cost, high conversion lift |
| Real-time analytics dashboard | TanStack Query with refetchInterval + Zustand for UI config | Background polling keeps data fresh; global store handles theme/layout without re-rendering charts | Moderate network cost, high data accuracy |
| Internal admin panel with complex forms | Local state + React Hook Form + Context for auth | Form state stays isolated; Context handles session without broadcast overhead | Low complexity, fast development |
| Multi-tenant SaaS with workspace settings | Zustand with selectors + TanStack Query for tenant data | Fine-grained subscriptions prevent cross-tenant UI updates; query cache isolates data | Minimal re-render cost, high scalability |
Configuration Template
// queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
retry: 2,
refetchOnWindowFocus: false,
},
},
});
// store.ts
import { create } from 'zustand';
interface AppSettings {
sidebarCollapsed: boolean;
setSidebar: (collapsed: boolean) => void;
}
export const useAppSettings = create<AppSettings>((set) => ({
sidebarCollapsed: false,
setSidebar: (collapsed) => set({ sidebarCollapsed: collapsed }),
}));
// urlSync.ts
import { useEffect, useState } from 'react';
export function useSyncUrlState<T extends Record<string, string>>(keys: (keyof T)[]) {
const [state, setState] = useState<T>(() => {
const params = new URLSearchParams(window.location.search);
return Object.fromEntries(keys.map(k => [k, params.get(k as string) || ''])) as T;
});
useEffect(() => {
const params = new URLSearchParams();
(Object.entries(state) as [string, string][]).forEach(([k, v]) => {
if (v) params.set(k, v);
});
window.history.replaceState(null, '', `${window.location.pathname}?${params}`);
}, [state]);
return [state, setState] as const;
}
Quick Start Guide
- Initialize the query client: Import
QueryClient and wrap your application root with QueryClientProvider. Configure default staleTime and gcTime based on your data volatility.
- Create the global store: Use
create from Zustand to define cross-cutting settings. Export individual selectors to prevent unnecessary re-renders in consuming components.
- Wire URL synchronization: Import the
useSyncUrlState hook in your main layout or filter components. Pass only shareable keys (page, sort, filters) to avoid URL pollution.
- Replace manual fetching: Swap
useEffect + fetch patterns with useQuery and useMutation. Define query keys that match your filter parameters to enable automatic cache invalidation.
- Verify the flow: Open browser dev tools, trigger a filter change, and confirm the URL updates without history pollution. Check the Network tab to verify cached responses are served on repeated queries.