RE
State Management
React core v1.0.0
React State Management
Overview
State management in React ranges from local component state to global application state. This skill covers useState, useReducer, Context API, and external state libraries like Zustand and Redux Toolkit.
Key Concepts
State Management Hierarchy
┌─────────────────────────────────────────────────────────────┐
│ State Management Spectrum │
├─────────────────────────────────────────────────────────────┤
│ │
│ Scope │ Solution │ Use Case │
│ ────────────────────┼───────────────────┼─────────────────│
│ Component │ useState │ UI state, forms │
│ Component (complex) │ useReducer │ Multi-field forms│
│ Parent-Child │ Props │ Direct passing │
│ Subtree │ Context │ Theme, auth │
│ Feature │ Zustand slice │ Feature state │
│ Global │ Redux/Zustand │ App-wide state │
│ Server │ React Query │ API data │
│ URL │ Router state │ Navigation │
│ │
│ State Categories: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Server State ────────▶ React Query / SWR │ │
│ │ • Cached, async │ │
│ │ • Has loading/error states │ │
│ │ • Owned by server │ │
│ │ │ │
│ │ Client State ────────▶ Zustand / Redux / Context │ │
│ │ • Synchronous │ │
│ │ • Owned by client │ │
│ │ • UI state, preferences │ │
│ │ │ │
│ │ URL State ───────────▶ React Router │ │
│ │ • Shareable, bookmarkable │ │
│ │ • Filters, pagination │ │
│ │ │ │
│ │ Form State ──────────▶ React Hook Form │ │
│ │ • Validation, submission │ │
│ │ • Performance optimized │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Best Practices
1. Colocate State
Keep state as close to where it’s used as possible.
2. Separate Server and Client State
Use React Query for server state, local solutions for UI state.
3. Derive State When Possible
Compute values from existing state instead of storing redundantly.
4. Use Immutable Updates
Never mutate state directly; create new references.
5. Normalize Complex Data
Use normalized structures for entity-heavy data.
Code Examples
Example 1: useReducer for Complex State
import { useReducer, useCallback } from 'react';
// State type
interface FormState {
values: Record<string, string>;
errors: Record<string, string>;
touched: Record<string, boolean>;
isSubmitting: boolean;
isValid: boolean;
}
// Action types
type FormAction =
| { type: 'SET_VALUE'; field: string; value: string }
| { type: 'SET_ERROR'; field: string; error: string }
| { type: 'SET_TOUCHED'; field: string }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'SUBMIT_ERROR'; errors: Record<string, string> }
| { type: 'RESET' };
// Reducer
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_VALUE':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: '' },
};
case 'SET_ERROR':
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
isValid: false,
};
case 'SET_TOUCHED':
return {
...state,
touched: { ...state.touched, [action.field]: true },
};
case 'SUBMIT_START':
return { ...state, isSubmitting: true };
case 'SUBMIT_SUCCESS':
return { ...state, isSubmitting: false };
case 'SUBMIT_ERROR':
return {
...state,
isSubmitting: false,
errors: action.errors,
isValid: false,
};
case 'RESET':
return initialState;
default:
return state;
}
}
const initialState: FormState = {
values: {},
errors: {},
touched: {},
isSubmitting: false,
isValid: true,
};
// Custom hook
function useForm<T extends Record<string, string>>(
initialValues: T,
validate: (values: T) => Record<string, string>
) {
const [state, dispatch] = useReducer(formReducer, {
...initialState,
values: initialValues,
});
const setValue = useCallback((field: keyof T, value: string) => {
dispatch({ type: 'SET_VALUE', field: String(field), value });
}, []);
const setTouched = useCallback((field: keyof T) => {
dispatch({ type: 'SET_TOUCHED', field: String(field) });
}, []);
const handleSubmit = useCallback(
async (onSubmit: (values: T) => Promise<void>) => {
dispatch({ type: 'SUBMIT_START' });
const errors = validate(state.values as T);
if (Object.keys(errors).length > 0) {
dispatch({ type: 'SUBMIT_ERROR', errors });
return;
}
try {
await onSubmit(state.values as T);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({
type: 'SUBMIT_ERROR',
errors: { form: 'Submission failed' },
});
}
},
[state.values, validate]
);
const reset = useCallback(() => {
dispatch({ type: 'RESET' });
}, []);
return {
values: state.values as T,
errors: state.errors,
touched: state.touched,
isSubmitting: state.isSubmitting,
isValid: state.isValid,
setValue,
setTouched,
handleSubmit,
reset,
};
}
// Usage
function SignupForm() {
const { values, errors, isSubmitting, setValue, handleSubmit } = useForm(
{ email: '', password: '', name: '' },
(values) => {
const errors: Record<string, string> = {};
if (!values.email.includes('@')) errors.email = 'Invalid email';
if (values.password.length < 8) errors.password = 'Too short';
return errors;
}
);
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(submitFn); }}>
<input
value={values.email}
onChange={(e) => setValue('email', e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
}
Example 2: Context with Optimized Rendering
import React, { createContext, useContext, useReducer, useMemo, ReactNode } from 'react';
// Split context to prevent unnecessary re-renders
interface AuthState {
user: User | null;
isLoading: boolean;
error: string | null;
}
interface AuthActions {
login: (credentials: Credentials) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<void>;
}
const AuthStateContext = createContext<AuthState | null>(null);
const AuthActionsContext = createContext<AuthActions | null>(null);
// Provider
function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialAuthState);
// Memoize actions to prevent re-creation
const actions = useMemo<AuthActions>(
() => ({
login: async (credentials) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await authAPI.login(credentials);
dispatch({ type: 'LOGIN_SUCCESS', user });
} catch (error) {
dispatch({ type: 'LOGIN_ERROR', error: error.message });
}
},
logout: () => {
authAPI.logout();
dispatch({ type: 'LOGOUT' });
},
refreshToken: async () => {
try {
const user = await authAPI.refreshToken();
dispatch({ type: 'REFRESH_SUCCESS', user });
} catch (error) {
dispatch({ type: 'LOGOUT' });
}
},
}),
[]
);
return (
<AuthStateContext.Provider value={state}>
<AuthActionsContext.Provider value={actions}>
{children}
</AuthActionsContext.Provider>
</AuthStateContext.Provider>
);
}
// Hooks
function useAuthState() {
const context = useContext(AuthStateContext);
if (!context) throw new Error('useAuthState must be within AuthProvider');
return context;
}
function useAuthActions() {
const context = useContext(AuthActionsContext);
if (!context) throw new Error('useAuthActions must be within AuthProvider');
return context;
}
// Selector hook for specific state
function useUser() {
const { user } = useAuthState();
return user;
}
function useIsAuthenticated() {
const { user, isLoading } = useAuthState();
return { isAuthenticated: !!user, isLoading };
}
// Usage - only re-renders when relevant state changes
function UserAvatar() {
const user = useUser(); // Only re-renders when user changes
if (!user) return null;
return <img src={user.avatar} alt={user.name} />;
}
function LoginButton() {
const { login } = useAuthActions(); // Never re-renders from state changes
const { isAuthenticated } = useIsAuthenticated();
if (isAuthenticated) return null;
return <button onClick={() => login({ email: '', password: '' })}>Login</button>;
}
Example 3: Zustand Store
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
// Define store types
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
isOpen: boolean;
}
interface CartActions {
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
updateQuantity: (id: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
}
type CartStore = CartState & CartActions;
// Create store with middleware
const useCartStore = create<CartStore>()(
devtools(
persist(
immer((set, get) => ({
// State
items: [],
isOpen: false,
// Actions
addItem: (item) =>
set((state) => {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += 1;
} else {
state.items.push({ ...item, quantity: 1 });
}
}),
removeItem: (id) =>
set((state) => {
state.items = state.items.filter((i) => i.id !== id);
}),
updateQuantity: (id, quantity) =>
set((state) => {
const item = state.items.find((i) => i.id === id);
if (item) {
if (quantity <= 0) {
state.items = state.items.filter((i) => i.id !== id);
} else {
item.quantity = quantity;
}
}
}),
clearCart: () =>
set((state) => {
state.items = [];
}),
toggleCart: () =>
set((state) => {
state.isOpen = !state.isOpen;
}),
})),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
// Derived state selectors
const selectItemCount = (state: CartStore) =>
state.items.reduce((sum, item) => sum + item.quantity, 0);
const selectTotal = (state: CartStore) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// Usage with selectors
function CartIcon() {
const count = useCartStore(selectItemCount);
const toggle = useCartStore((state) => state.toggleCart);
return (
<button onClick={toggle}>
Cart ({count})
</button>
);
}
function CartTotal() {
const total = useCartStore(selectTotal);
return <div>Total: ${total.toFixed(2)}</div>;
}
function CartItemList() {
const items = useCartStore((state) => state.items);
const updateQuantity = useCartStore((state) => state.updateQuantity);
const removeItem = useCartStore((state) => state.removeItem);
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name} - ${item.price} x {item.quantity}
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
-
</button>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
+
</button>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
Example 4: React Query for Server State
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
// Query keys factory
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};
// API functions
async function fetchUsers(filters: UserFilters): Promise<User[]> {
const params = new URLSearchParams(filters as any);
const response = await fetch(`/api/users?${params}`);
if (!response.ok) throw new Error('Failed to fetch users');
return response.json();
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
async function updateUser(id: string, data: Partial<User>): Promise<User> {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update user');
return response.json();
}
// Custom hooks
function useUsers(filters: UserFilters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
function useUser(id: string) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 5 * 60 * 1000,
enabled: !!id,
});
}
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) =>
updateUser(id, data),
// Optimistic update
onMutate: async ({ id, data }) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: userKeys.detail(id) });
// Snapshot previous value
const previousUser = queryClient.getQueryData<User>(userKeys.detail(id));
// Optimistically update
if (previousUser) {
queryClient.setQueryData(userKeys.detail(id), {
...previousUser,
...data,
});
}
return { previousUser };
},
// Rollback on error
onError: (err, { id }, context) => {
if (context?.previousUser) {
queryClient.setQueryData(userKeys.detail(id), context.previousUser);
}
},
// Invalidate on success
onSettled: (_, __, { id }) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
const updateUser = useUpdateUser();
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<button
onClick={() =>
updateUser.mutate({
id: userId,
data: { name: 'Updated Name' },
})
}
disabled={updateUser.isPending}
>
{updateUser.isPending ? 'Updating...' : 'Update Name'}
</button>
</div>
);
}
Example 5: URL State Management
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useMemo, useCallback } from 'react';
// Custom hook for URL-based state
interface FilterState {
search: string;
category: string;
sortBy: 'date' | 'price' | 'name';
sortOrder: 'asc' | 'desc';
page: number;
limit: number;
}
const defaultFilters: FilterState = {
search: '',
category: '',
sortBy: 'date',
sortOrder: 'desc',
page: 1,
limit: 20,
};
function useUrlFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo<FilterState>(() => ({
search: searchParams.get('search') || defaultFilters.search,
category: searchParams.get('category') || defaultFilters.category,
sortBy: (searchParams.get('sortBy') as FilterState['sortBy']) || defaultFilters.sortBy,
sortOrder: (searchParams.get('sortOrder') as FilterState['sortOrder']) || defaultFilters.sortOrder,
page: parseInt(searchParams.get('page') || String(defaultFilters.page), 10),
limit: parseInt(searchParams.get('limit') || String(defaultFilters.limit), 10),
}), [searchParams]);
const setFilters = useCallback(
(updates: Partial<FilterState>) => {
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
Object.entries(updates).forEach(([key, value]) => {
if (value === defaultFilters[key as keyof FilterState] || value === '') {
newParams.delete(key);
} else {
newParams.set(key, String(value));
}
});
// Reset page when other filters change
if (!('page' in updates)) {
newParams.delete('page');
}
return newParams;
});
},
[setSearchParams]
);
const resetFilters = useCallback(() => {
setSearchParams(new URLSearchParams());
}, [setSearchParams]);
return { filters, setFilters, resetFilters };
}
// Usage
function ProductList() {
const { filters, setFilters, resetFilters } = useUrlFilters();
const { data: products, isLoading } = useProducts(filters);
return (
<div>
<input
value={filters.search}
onChange={(e) => setFilters({ search: e.target.value })}
placeholder="Search..."
/>
<select
value={filters.sortBy}
onChange={(e) => setFilters({ sortBy: e.target.value as FilterState['sortBy'] })}
>
<option value="date">Date</option>
<option value="price">Price</option>
<option value="name">Name</option>
</select>
<button onClick={resetFilters}>Reset Filters</button>
{isLoading ? (
<Spinner />
) : (
<ProductGrid products={products} />
)}
<Pagination
page={filters.page}
onPageChange={(page) => setFilters({ page })}
/>
</div>
);
}
Anti-Patterns
❌ Storing Derived State
// WRONG - storing computed values
const [items, setItems] = useState<Item[]>([]);
const [total, setTotal] = useState(0);
// Must manually keep total in sync
// ✅ CORRECT - derive from source state
const [items, setItems] = useState<Item[]>([]);
const total = useMemo(() => items.reduce((sum, i) => sum + i.price, 0), [items]);
❌ Mutating State Directly
// WRONG - mutation
state.items.push(newItem);
setState(state);
// ✅ CORRECT - immutable update
setState({ ...state, items: [...state.items, newItem] });