= useCallback((newMetric: ReportConfig['metric']) => {
dispatch({ type: 'SET_METRIC', metric: newMetric });
}, []);
const toggleSort = useCallback(() => {
dispatch({ type: 'TOGGLE_SORT' });
}, []);
return (
<div className="report-controls">
<select
value={config.metric}
onChange={(e) => handleMetricChange(e.target.value as ReportConfig['metric'])}
>
<option value="revenue">Revenue</option>
<option value="users">Active Users</option>
<option value="churn">Churn Rate</option>
</select>
<button onClick={toggleSort}>
Sort: {config.sortDirection === 'asc' ? 'Ascending' : 'Descending'}
</button>
{config.isLoading && <span className="spinner">Fetching data...</span>}
</div>
);
}
**Rationale:** Using `useReducer` here prevents the "state explosion" anti-pattern where multiple `useState` hooks must be updated in sync. The reducer acts as a single source of truth for update logic, making the component easier to debug and refactor.
#### 2. Server State: TanStack Query for Async Data
Server state is fundamentally different from client state. It is asynchronous, requires caching to avoid redundant network requests, and benefits from background refetching. Placing API data in global stores forces manual cache invalidation and deduplication logic. TanStack Query automates these patterns.
**Implementation: Deployment Pipeline Monitor**
This component fetches deployment status and supports optimistic updates for triggering new builds.
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface Deployment {
id: string;
status: 'pending' | 'building' | 'success' | 'failed';
commitHash: string;
}
async function fetchDeployment(id: string): Promise<Deployment> {
const res = await fetch(`/api/deployments/${id}`);
if (!res.ok) throw new Error('Fetch failed');
return res.json();
}
async function triggerDeployment(id: string): Promise<void> {
const res = await fetch(`/api/deployments/${id}/trigger`, { method: 'POST' });
if (!res.ok) throw new Error('Trigger failed');
}
export function DeploymentMonitor({ deploymentId }: { deploymentId: string }) {
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['deployment', deploymentId],
queryFn: () => fetchDeployment(deploymentId),
staleTime: 10_000, // Data considered fresh for 10 seconds
refetchInterval: 5_000, // Poll every 5s for active deployments
});
const triggerMutation = useMutation({
mutationFn: () => triggerDeployment(deploymentId),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['deployment', deploymentId] });
const previous = queryClient.getQueryData(['deployment', deploymentId]);
// Optimistic update
queryClient.setQueryData(['deployment', deploymentId], {
...previous,
status: 'pending',
} as Deployment);
return { previous };
},
onError: (_err, _vars, context) => {
// Rollback on failure
if (context?.previous) {
queryClient.setQueryData(['deployment', deploymentId], context.previous);
}
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['deployment', deploymentId] });
},
});
if (isLoading) return <div>Loading deployment status...</div>;
if (error) return <div className="error">Error: {error.message}</div>;
if (!data) return null;
return (
<div className="deployment-card">
<h3>Deployment {data.id}</h3>
<p>Status: <span className={`status-${data.status}`}>{data.status}</span></p>
<p>Commit: {data.commitHash}</p>
<button
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.isPending}
>
{triggerMutation.isPending ? 'Triggering...' : 'Redeploy'}
</button>
</div>
);
}
Rationale: TanStack Query handles cache management, deduplication, and background updates automatically. The optimistic update pattern provides instant UI feedback while maintaining data integrity through rollback mechanisms. This eliminates the need for manual cache logic in global stores.
3. Global UI State: Zustand for Cross-Component Data
Global UI state includes data shared across distant components, such as user preferences, feature flags, or session data. Context API is suitable for read-heavy, write-rarely scenarios but can cause re-render storms if updated frequently. Zustand offers a lightweight, selector-based alternative without provider wrapping.
Implementation: Workspace Preferences Store
This store manages user workspace settings with persistence and devtools integration.
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
interface WorkspaceState {
sidebarCollapsed: boolean;
theme: 'system' | 'light' | 'dark';
notificationsEnabled: boolean;
toggleSidebar: () => void;
setTheme: (theme: WorkspaceState['theme']) => void;
toggleNotifications: () => void;
}
export const useWorkspaceStore = create<WorkspaceState>()(
devtools(
persist(
(set) => ({
sidebarCollapsed: false,
theme: 'system',
notificationsEnabled: true,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setTheme: (theme) => set({ theme }),
toggleNotifications: () => set((state) => ({ notificationsEnabled: !state.notificationsEnabled })),
}),
{
name: 'workspace-storage',
partialize: (state) => ({
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
}),
}
)
)
);
export function Sidebar() {
const isCollapsed = useWorkspaceStore((state) => state.sidebarCollapsed);
const toggle = useWorkspaceStore((state) => state.toggleSidebar);
return (
<aside className={`sidebar ${isCollapsed ? 'collapsed' : ''}`}>
<button onClick={toggle}>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
{/* Sidebar content */}
</aside>
);
}
Rationale: Zustand's selector-based subscriptions ensure components only re-render when the specific slice of state they consume changes. The persist middleware automatically syncs state to localStorage, and devtools provides time-travel debugging without additional setup. This approach avoids the boilerplate of Redux while offering superior performance over Context for frequent updates.
4. URL State: nuqs for Shareable Filters
URL state represents data that should be shareable, bookmarkable, and synchronized with browser history. Filters, pagination, and search queries belong in the URL. Libraries like nuqs provide type-safe synchronization between URL search parameters and component state.
Implementation: Audit Log Viewer
This component syncs search and filter parameters with the URL, enabling users to share specific views.
import { useQueryState, parseAsString, parseAsInteger } from 'nuqs';
export function AuditLogViewer() {
const [searchQuery, setSearchQuery] = useQueryState('q', parseAsString.withDefault(''));
const [severity, setSeverity] = useQueryState('severity', parseAsString.withDefault('all'));
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const handleSearch = (value: string) => {
setSearchQuery(value);
setPage(1); // Reset page on new search
};
return (
<div className="audit-log">
<div className="controls">
<input
type="text"
placeholder="Search logs..."
value={searchQuery || ''}
onChange={(e) => handleSearch(e.target.value)}
/>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
>
<option value="all">All Severities</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
<div className="log-entries">
{/* Render logs based on searchQuery, severity, page */}
<p>Viewing page {page} for severity: {severity}</p>
</div>
<div className="pagination">
<button onClick={() => setPage(p => p - 1)} disabled={page <= 1}>Prev</button>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</div>
</div>
);
}
Rationale: Synchronizing state with the URL ensures that application state is serializable and shareable. nuqs handles parsing, serialization, and history management automatically. This pattern eliminates the need for manual useEffect hooks to sync URL params and prevents state loss on page refresh.
Pitfall Guide
-
The Global Store Trap
- Explanation: Storing ephemeral UI state (e.g., modal open/close, form input values) in a global store like Redux or Zustand.
- Fix: Keep state local to the component using
useState or useReducer. Only lift state when multiple components require access.
-
Server State in Global Stores
- Explanation: Fetching API data and storing it in a global store. This bypasses caching, deduplication, and background refetching, leading to stale data and redundant requests.
- Fix: Use TanStack Query or SWR for all server state. Reserve global stores for client-only data.
-
Context Re-render Storms
- Explanation: Updating React Context frequently causes all consumers to re-render, even if they only use a small part of the context value.
- Fix: Split contexts by update frequency or switch to Zustand for selector-based subscriptions. Use
useMemo for context values when possible.
-
Derived State Duplication
- Explanation: Storing computed values in state (e.g., storing
fullName when firstName and lastName exist). This creates synchronization bugs and increases memory usage.
- Fix: Compute derived values during render. Use
useMemo only if the computation is expensive.
-
Ignoring URL State for Filters
- Explanation: Keeping filters in local state prevents users from bookmarking or sharing specific views. State is lost on refresh.
- Fix: Synchronize filters, pagination, and search queries with the URL using
nuqs or router search params.
-
Over-Optimizing Local State
- Explanation: Using
useReducer or complex patterns for simple toggles or single values. This adds unnecessary complexity.
- Fix: Use
useState for simple state. Reserve useReducer for complex update logic or when next state depends on previous state.
-
Missing Error Boundaries for Async State
- Explanation: Failing to handle error states in server queries, leading to unhandled exceptions in the UI.
- Fix: Always check for
error in query hooks and render fallback UI. Implement global error handling for critical mutations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Real-time Chat | Zustand + WebSocket | Low latency, no caching needed, high update frequency | Low |
| CRUD Dashboard | TanStack Query | Native caching, background updates, optimistic mutations | Medium |
| Complex Form | React Hook Form + Local | Performance optimization, validation, isolated state | Low |
| Enterprise ERP | Redux Toolkit | Predictability, time-travel, strict structure for large teams | High |
| User Preferences | Zustand + Persist | Lightweight, selector-based, automatic persistence | Low |
| Search/Filter | nuqs | Shareable, bookmarkable, history-aware | Low |
Configuration Template
TanStack Query Client Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
retry: 2,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Quick Start Guide
- Install Dependencies:
npm install @tanstack/react-query zustand nuqs
- Wrap Application:
Add
QueryClientProvider to your root component to enable server state management.
- Create Store:
Define a Zustand store for global UI state using
create and persist middleware.
- Define Queries:
Replace API calls in components with
useQuery hooks, specifying queryKey and queryFn.
- Sync URL:
Replace local filter state with
useQueryState from nuqs to enable shareable views.