React State Management
Overview
State management is one of the most critical architectural decisions in React applications. This guide covers state management strategies with Zustand as the primary global state solution, React Query for server state, and Context for localized shared state.
The fundamental insight: Not all state is the same. Server state (data from APIs) has different characteristics than client state (UI preferences, form data). Using the right tool for each type of state simplifies your application architecture and improves performance.
Why Multiple Solutions?
Modern applications have three distinct categories of state:
- Server state (data from APIs): Asynchronous, potentially stale, requires caching and synchronization
- Global client state (app-wide UI state): Theme, user preferences, global filters
- Local state (component-specific): Form inputs, modal open/closed, temporary UI state
Each category benefits from specialized tools. Using React Query for server state eliminates the need to manually manage loading states, caching, refetching, and error handling. Using Zustand for global client state provides a simpler API than Redux with better TypeScript support. Using local state (useState/useReducer) keeps components self-contained.
Core Principles
1. Server State ≠ Client State
Server state originates from external sources (APIs, databases). It's asynchronous, may become stale, requires caching, and needs background refetching to stay fresh. React Query specializes in these challenges.
Client state lives entirely in the browser. It's synchronous, never stale (it's the source of truth), and doesn't need caching or refetching. Zustand and useState handle this efficiently.
Common mistake: Storing server data in Zustand/Redux. This duplicates React Query's functionality and forces you to manually handle loading states, caching, and refetching.
2. Local State First
Start with useState or useReducer for component-local state. Only lift state to global stores when multiple components genuinely need it. Premature globalization adds complexity without benefits.
Questions to ask:
- Is this state used by multiple components? (If no, keep it local)
- Is this state shared by sibling components? (If yes, consider lifting to parent or Context)
- Is this state needed across routes? (If yes, consider Zustand)
3. Zustand for Global Client State
Zustand provides a simple, hooks-based API for global state without the boilerplate of Redux. It's TypeScript-first, supports middleware (persist, devtools), and works seamlessly with React 18.
Use Zustand for:
- User preferences (theme, language, display settings)
- Global UI state (sidebar open/closed, active filters)
- Derived data that depends on multiple sources
- State shared across distant components
4. Immutability Always
Never mutate state directly. Always create new objects/arrays. This enables:
- React's shallow equality checks for re-render optimization
- Time-travel debugging (state history)
- Predictable state updates
// BAD: Mutation
state.users.push(newUser);
// GOOD: Immutable update
set({ users: [...state.users, newUser] });
5. Type Safety
Leverage TypeScript to define state shapes and action signatures. This catches errors at compile-time and provides excellent autocomplete in IDEs.
6. Single Source of Truth
Each piece of state should have exactly one source of truth. Don't duplicate data across multiple stores or mix server data into client state stores.
State Classification
Decision Tree
State Types
| Type | Solution | Examples |
|---|---|---|
| Local UI State | useState | Form inputs, modal open/closed, dropdown selection |
| Server State | React Query | Payments, accounts, transactions (API data) |
| Global Client State | Zustand | User preferences, theme, selected filters |
| Localized Shared State | Context API | Form wizard state, multi-step process |
Zustand (Primary Global State)
Zustand is a small, fast state management solution that uses hooks and doesn't require providers/Context. It creates stores that components can subscribe to, and components only re-render when the specific state they're using changes.
Why Zustand?
Compared to Redux:
- 90% less boilerplate: No actions, action creators, reducers, or combiners
- No Provider required: Access stores anywhere without wrapping components
- Better TypeScript: Full type inference without complex type definitions
- Simpler mental model: Just create a store and use it
Compared to Context:
- Better performance: Granular subscriptions prevent unnecessary re-renders
- No provider hell: Don't nest 5+ Context providers
- DevTools: Redux DevTools integration out of the box
Compared to Redux Toolkit:
- Simpler API: Less to learn, less configuration
- Smaller bundle: ~1KB vs ~20KB (Redux Toolkit + React-Redux)
- Same power: Middleware, persist, devtools all available
Key features:
- TypeScript-first: Excellent type inference and autocomplete
- Flexible: Works with React 18 concurrent features, SSR, and React Native
- Transient updates: Update state without causing re-renders (for high-frequency updates)
- Computed values: Derive state from other state efficiently
Installation
npm install zustand
Basic Store
A Zustand store is created with the create function. The store contains both state and actions (functions that update state). Components subscribe by calling the hook.
// stores/usePaymentFilters.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface PaymentFiltersState {
status: 'all' | 'pending' | 'completed' | 'failed';
currency: string;
dateRange: { start: Date; end: Date } | null;
searchTerm: string;
}
interface PaymentFiltersActions {
setStatus: (status: PaymentFiltersState['status']) => void;
setCurrency: (currency: string) => void;
setDateRange: (range: PaymentFiltersState['dateRange']) => void;
setSearchTerm: (term: string) => void;
reset: () => void;
}
type PaymentFiltersStore = PaymentFiltersState & PaymentFiltersActions;
const initialState: PaymentFiltersState = {
status: 'all',
currency: 'USD',
dateRange: null,
searchTerm: ''
};
export const usePaymentFilters = create<PaymentFiltersStore>()(
devtools(
persist(
(set) => ({
...initialState,
setStatus: (status) => set({ status }),
setCurrency: (currency) => set({ currency }),
setDateRange: (dateRange) => set({ dateRange }),
setSearchTerm: (searchTerm) => set({ searchTerm }),
reset: () => set(initialState)
}),
{
name: 'payment-filters' // LocalStorage key
}
)
)
);
Using the Store
// components/PaymentFilters.tsx
import { usePaymentFilters } from '@/stores/usePaymentFilters';
export function PaymentFilters() {
const status = usePaymentFilters((state) => state.status);
const setStatus = usePaymentFilters((state) => state.setStatus);
const reset = usePaymentFilters((state) => state.reset);
// GOOD: Select only needed state (prevents unnecessary re-renders)
return (
<div>
<select value={status} onChange={(e) => setStatus(e.target.value as any)}>
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
<button onClick={reset}>Reset Filters</button>
</div>
);
}
// BAD: Selecting entire store causes re-renders on any state change
function BadComponent() {
const store = usePaymentFilters(); // Re-renders on ANY state change
return <div>{store.status}</div>;
}
Computed Values (Selectors)
// Derive state in the store
export const usePaymentFilters = create<PaymentFiltersStore>()((set, get) => ({
// ... state and actions
// Computed value
hasActiveFilters: () => {
const state = get();
return (
state.status !== 'all' ||
state.currency !== 'USD' ||
state.dateRange !== null ||
state.searchTerm !== ''
);
}
}));
// Usage
function FilterBadge() {
const hasActiveFilters = usePaymentFilters((state) => state.hasActiveFilters());
if (!hasActiveFilters) return null;
return <span className="badge">Filters Active</span>;
}
Async Actions
// stores/useNotifications.ts
import { create } from 'zustand';
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
timestamp: Date;
}
interface NotificationStore {
notifications: Notification[];
addNotification: (message: string, type: Notification['type']) => void;
removeNotification: (id: string) => void;
clearAll: () => void;
}
export const useNotifications = create<NotificationStore>((set) => ({
notifications: [],
addNotification: (message, type) => {
const id = crypto.randomUUID();
const notification: Notification = {
id,
message,
type,
timestamp: new Date()
};
set((state) => ({
notifications: [...state.notifications, notification]
}));
// Auto-remove after 5 seconds
setTimeout(() => {
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id)
}));
}, 5000);
},
removeNotification: (id) =>
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id)
})),
clearAll: () => set({ notifications: [] })
}));
Slices Pattern (Large Stores)
// stores/slices/userSlice.ts
import { StateCreator } from 'zustand';
export interface UserSlice {
user: User | null;
isAuthenticated: boolean;
login: (user: User) => void;
logout: () => void;
}
export const createUserSlice: StateCreator<AppStore, [], [], UserSlice> = (set) => ({
user: null,
isAuthenticated: false,
login: (user) => set({ user, isAuthenticated: true }),
logout: () => set({ user: null, isAuthenticated: false })
});
// stores/slices/preferencesSlice.ts
export interface PreferencesSlice {
theme: 'light' | 'dark';
language: string;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
}
export const createPreferencesSlice: StateCreator<AppStore, [], [], PreferencesSlice> = (set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language })
});
// stores/useAppStore.ts
import { create } from 'zustand';
import { createUserSlice, UserSlice } from './slices/userSlice';
import { createPreferencesSlice, PreferencesSlice } from './slices/preferencesSlice';
type AppStore = UserSlice & PreferencesSlice;
export const useAppStore = create<AppStore>()((...a) => ({
...createUserSlice(...a),
...createPreferencesSlice(...a)
}));
// Usage: Access slices independently
function Header() {
const theme = useAppStore((state) => state.theme);
const user = useAppStore((state) => state.user);
return <header className={theme}>{user?.name}</header>;
}
React Query (Server State)
React Query (TanStack Query) is a data synchronization library for managing server state. It treats server data as a cache that needs to be synchronized with the actual source of truth (the backend).
Why React Query?
The problem it solves: Managing server state is fundamentally different from managing client state. Server state is:
- Asynchronous: Requires loading states
- Potentially stale: Data may be outdated
- Shared across components: Multiple components may need the same data
- Subject to errors: Network issues, server errors, timeouts
- Expensive to fetch: Should be cached and refetched intelligently
Manually managing this with useState/useEffect leads to boilerplate, bugs, and performance issues. React Query handles it automatically.
Key features:
- Automatic caching: Data is cached by query key. Subsequent requests use cached data instantly.
- Background refetching: Cached data is automatically refetched in the background to stay fresh (configurable windows).
- Deduplication: Multiple components requesting the same data trigger only one network request.
- Smart refetching: Refetches on window focus, network reconnection, and at configurable intervals.
- Built-in loading/error states: No manual boolean flags needed.
- Optimistic updates: Update UI before server confirms for better perceived performance.
- Pagination & infinite scroll: First-class support for paginated data.
- Request cancellation: Automatically cancels in-flight requests when components unmount.
Compared to manual data fetching:
// BAD: Manual approach (40+ lines of boilerplate)
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch('/api/payments')
.then(res => res.json())
.then(data => {
if (!cancelled) setData(data);
})
.catch(err => {
if (!cancelled) setError(err);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
// GOOD: React Query (3 lines)
const { data, isLoading, error } = useQuery({
queryKey: ['payments'],
queryFn: fetchPayments
});
Installation
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools
Setup
The QueryClient configures global defaults for all queries. Wrap your app in QueryClientProvider to make the client available.
// App.tsx
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
retry: 3,
refetchOnWindowFocus: true
}
}
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Basic Query
// api/payments.ts
export async function fetchPayments(): Promise<Payment[]> {
const response = await fetch('/api/payments');
if (!response.ok) {
throw new Error('Failed to fetch payments');
}
return response.json();
}
export async function fetchPaymentById(id: string): Promise<Payment> {
const response = await fetch(`/api/payments/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch payment');
}
return response.json();
}
// components/PaymentList.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchPayments } from '@/api/payments';
export function PaymentList() {
const {
data: payments,
isLoading,
error,
refetch
} = useQuery({
queryKey: ['payments'],
queryFn: fetchPayments
});
if (isLoading) return <div>Loading payments...</div>;
if (error) {
return (
<div>
Error: {error.message}
<button onClick={() => refetch()}>Retry</button>
</div>
);
}
return (
<div>
{payments?.map((payment) => (
<PaymentCard key={payment.id} payment={payment} />
))}
</div>
);
}
Query with Parameters
function PaymentDetails({ paymentId }: { paymentId: string }) {
const { data: payment, isLoading } = useQuery({
queryKey: ['payment', paymentId], // Key includes parameter
queryFn: () => fetchPaymentById(paymentId),
enabled: !!paymentId // Only run if paymentId exists
});
if (isLoading) return <div>Loading...</div>;
return <div>{payment?.amount}</div>;
}
Mutations
Mutations are used for create, update, and delete operations. Unlike queries (which fetch data), mutations modify data on the server.
Key differences from queries:
- No automatic caching: Mutations don't cache results
- No automatic refetching: You control when related queries update
- Manual cache updates: You decide how to update the cache after mutation
Two approaches after mutations:
- Invalidate queries: Tell React Query that data is stale and needs refetching (simpler, always correct)
- Optimistic updates: Immediately update the cache with expected result (faster UX, more complex)
// api/payments.ts
export async function createPayment(
data: CreatePaymentRequest
): Promise<Payment> {
const response = await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('Failed to create payment');
}
return response.json();
}
// components/CreatePaymentForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createPayment } from '@/api/payments';
export function CreatePaymentForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPayment,
onSuccess: (newPayment) => {
// Approach 1: Invalidate and refetch (simpler, always correct)
queryClient.invalidateQueries({ queryKey: ['payments'] });
// Approach 2: Directly update cache (faster, but manual)
queryClient.setQueryData(['payments'], (old: Payment[] = []) => [
...old,
newPayment
]);
// Show success notification
showNotification('Payment created successfully');
},
onError: (error) => {
console.error('Failed to create payment:', error);
showNotification('Failed to create payment', 'error');
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
mutation.mutate({
amount: Number(formData.get('amount')),
currency: formData.get('currency') as string,
vendorId: formData.get('vendorId') as string
});
};
return (
<form onSubmit={handleSubmit}>
<input name="amount" type="number" required />
<input name="currency" type="text" required />
<input name="vendorId" type="text" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create Payment'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</form>
);
}
When to use invalidation vs. direct cache update:
- Invalidation (recommended): Always correct, simpler, handles complex server logic. Use when server may transform data (generate IDs, add timestamps, calculate totals).
- Direct update: Faster (no refetch), but requires you to match server's exact response. Use for simple creates where you know the exact result.
Optimistic Updates
Optimistic updates improve perceived performance by updating the UI immediately, before the server confirms the change. If the server request fails, the update is rolled back.
How it works:
- onMutate: Immediately update the cache with optimistic data, save previous data for rollback
- onError: If mutation fails, restore previous data from rollback context
- onSettled: Whether success or failure, refetch to ensure cache matches server
Use cases:
- Creating/deleting items in lists (payments, transactions)
- Toggling flags (mark as read, favorite, archive)
- Updating simple fields (rename, change status)
Don't use for:
- Operations where server calculates values (totals, balances)
- Operations that may fail validation
- Critical operations where showing incorrect data temporarily is unacceptable
const mutation = useMutation({
mutationFn: updatePayment,
onMutate: async (updatedPayment) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['payment', updatedPayment.id] });
// Snapshot previous value
const previousPayment = queryClient.getQueryData(['payment', updatedPayment.id]);
// Optimistically update
queryClient.setQueryData(['payment', updatedPayment.id], updatedPayment);
// Return context with snapshot
return { previousPayment };
},
onError: (err, updatedPayment, context) => {
// Rollback on error
queryClient.setQueryData(
['payment', updatedPayment.id],
context?.previousPayment
);
},
onSettled: (updatedPayment) => {
// Refetch after error or success
queryClient.invalidateQueries({ queryKey: ['payment', updatedPayment?.id] });
}
});
Pagination
interface PaginatedResponse<T> {
data: T[];
page: number;
totalPages: number;
}
function PaginatedPayments() {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['payments', page],
queryFn: () => fetchPaymentsPaginated(page),
placeholderData: (previousData) => previousData // Keep old data while fetching
});
return (
<div>
{data?.data.map((payment) => (
<PaymentCard key={payment.id} payment={payment} />
))}
<button
onClick={() => setPage((old) => Math.max(old - 1, 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page} of {data?.totalPages}</span>
<button
onClick={() => setPage((old) => old + 1)}
disabled={isPlaceholderData || page === data?.totalPages}
>
Next
</button>
</div>
);
}
Context API (Localized State)
Context provides a way to pass data through the component tree without manually passing props at every level. It's React's built-in solution for dependency injection and sharing state within a component subtree.
When to Use Context
Use Context for:
- Localized shared state: Multi-step forms, wizard workflows where steps share state
- Dependency injection: Theme, i18n, feature flags that children need but don't modify often
- Avoiding prop drilling: When passing props through 3+ intermediate components that don't use them
Don't use Context for:
- Global application state: Use Zustand instead - better performance, no provider needed
- Server state: Use React Query - handles caching, refetching, loading states
- Frequently changing state: All context consumers re-render when context value changes, even if they only use a small part
Performance consideration: Context causes all consuming components to re-render when the context value changes. This is fine for infrequent updates (theme changes, authentication state changes). For frequent updates, use Zustand or split into multiple contexts.
Context vs. Zustand:
| Feature | Context | Zustand |
|---|---|---|
| Provider required | Yes | No |
| Re-render behavior | All consumers re-render on any change | Only components using changed state re-render |
| DevTools | No | Yes (Redux DevTools) |
| Middleware | No | Yes (persist, devtools, etc.) |
| Best for | Localized state, dependency injection | Global state, frequent updates |
Basic Context
A typical Context implementation involves three parts:
- Create the context with
createContext - Provide the value with a Provider component
- Consume the value with
useContexthook
// contexts/PaymentFormContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface PaymentFormData {
step: number;
amount: number;
currency: string;
vendorId: string;
}
interface PaymentFormContextValue {
formData: PaymentFormData;
updateFormData: (data: Partial<PaymentFormData>) => void;
nextStep: () => void;
prevStep: () => void;
}
const PaymentFormContext = createContext<PaymentFormContextValue | null>(null);
export function PaymentFormProvider({ children }: { children: ReactNode }) {
const [formData, setFormData] = useState<PaymentFormData>({
step: 1,
amount: 0,
currency: 'USD',
vendorId: ''
});
const updateFormData = (data: Partial<PaymentFormData>) => {
setFormData((prev) => ({ ...prev, ...data }));
};
const nextStep = () => {
setFormData((prev) => ({ ...prev, step: prev.step + 1 }));
};
const prevStep = () => {
setFormData((prev) => ({ ...prev, step: Math.max(prev.step - 1, 1) }));
};
return (
<PaymentFormContext.Provider
value={{ formData, updateFormData, nextStep, prevStep }}
>
{children}
</PaymentFormContext.Provider>
);
}
// Custom hook
export function usePaymentForm() {
const context = useContext(PaymentFormContext);
if (!context) {
throw new Error('usePaymentForm must be used within PaymentFormProvider');
}
return context;
}
// Usage
function PaymentWizard() {
return (
<PaymentFormProvider>
<PaymentWizardSteps />
</PaymentFormProvider>
);
}
function PaymentWizardSteps() {
const { formData, nextStep, prevStep } = usePaymentForm();
return (
<div>
{formData.step === 1 && <Step1 />}
{formData.step === 2 && <Step2 />}
<button onClick={prevStep}>Back</button>
<button onClick={nextStep}>Next</button>
</div>
);
}
Combining Solutions
The most powerful approach combines all three solutions, using each for its intended purpose. This creates a clear separation of concerns and optimal performance.
Architecture Pattern
State flow decision:
- Is it from an API? → React Query
- Is it used globally across routes? → Zustand
- Is it shared by nearby components only? → Context (or lift to parent)
- Is it used by one component? → useState
// Global client state: Zustand
const useAppStore = create<AppStore>()((set) => ({
theme: 'light',
sidebarOpen: true,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen }))
}));
// Server state: React Query
function PaymentDashboard() {
// Server state
const { data: payments } = useQuery({
queryKey: ['payments'],
queryFn: fetchPayments
});
// Global client state
const theme = useAppStore((state) => state.theme);
const sidebarOpen = useAppStore((state) => state.sidebarOpen);
// Local UI state
const [selectedPaymentId, setSelectedPaymentId] = useState<string | null>(null);
return (
<div className={theme}>
{sidebarOpen && <Sidebar />}
<PaymentList
payments={payments}
selectedId={selectedPaymentId}
onSelect={setSelectedPaymentId}
/>
</div>
);
}
Common Anti-Patterns
1. Storing Server Data in Zustand
// BAD: Duplicating server state
const usePaymentStore = create((set) => ({
payments: [],
fetchPayments: async () => {
const data = await fetch('/api/payments').then((r) => r.json());
set({ payments: data });
}
}));
// GOOD: Use React Query for server state
const { data: payments } = useQuery({
queryKey: ['payments'],
queryFn: fetchPayments
});
2. Over-Using Context
// BAD: Using Context for global state
<ThemeContext>
<UserContext>
<NotificationContext>
<PaymentContext>
<App />
</PaymentContext>
</NotificationContext>
</UserContext>
</ThemeContext>
// GOOD: Use Zustand (no providers needed)
const useAppStore = create(/* ... */);
3. Not Selecting State Granularly
// BAD: Causes re-render on any state change
const store = usePaymentFilters();
// GOOD: Only re-renders when status changes
const status = usePaymentFilters((state) => state.status);
Further Reading
React Framework Guidelines
- React General - React fundamentals and hooks
- React Testing - Testing state management and hooks
- React Performance - Optimizing state updates and re-renders
- React Forms - Form state management with React Hook Form
Cross-Cutting Guidelines
- TypeScript Types - Type-safe state management
- OpenAPI Frontend Integration - Generating typed API clients for React Query
- Web State Management - Common state patterns across frameworks
- API Design - Understanding API contracts for client state
External Resources
Summary
Key Takeaways
- Use the right tool: React Query for server state, Zustand for global client state, Context for localized state
- Zustand is primary for global client state - simple API, no provider hell, excellent TypeScript support
- React Query manages server state - automatic caching, background refetching, optimistic updates
- Select state granularly in Zustand to prevent unnecessary re-renders
- Context for localized sharing - form wizards, dependency injection, not global state
- Don't duplicate server state - let React Query be the single source of truth for API data
- Middleware enhances Zustand - persist for localStorage, devtools for debugging
- Slices pattern scales large Zustand stores by splitting concerns
- Optimistic updates improve UX - update UI before server confirms
- Type safety throughout - leverage TypeScript for state definitions
Next Steps: Review React Testing for testing state management and React Performance for optimizing state updates.