State Management Design - Mid-Depth
State management is where most production bugs hide. You’ve got users editing the same document, browsers going offline mid-transaction, cache invalidation race conditions, and sessions timing out at unfortunate moments. This guide covers patterns that actually work when things get complicated.
1. Choosing State Management Patterns
The right pattern depends on your update frequency, sharing requirements, and complexity tolerance. There’s no universal solution.
React State Management Comparison
Context API - Good for infrequent updates
// UserContext.js
import { createContext, useContext, useState } from 'react';
const UserContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
// Problem: Every component re-renders when user changes
// Fine for auth state, terrible for frequently updating data
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
}
export const useUser = () => useContext(UserContext);
Context triggers re-renders in every consumer when the value changes. This works for auth state that changes once per session. It doesn’t work for real-time dashboards updating every second.
Zustand - Good for simple global state
// store.js
import create from 'zustand';
const useStore = create((set, get) => ({
users: [],
filter: '',
// Actions are just functions that call set
setFilter: (filter) => set({ filter }),
addUser: (user) => set((state) => ({
users: [...state.users, user]
})),
// Derived state via selectors
filteredUsers: () => {
const { users, filter } = get();
return users.filter(u => u.name.includes(filter));
}
}));
// Component only re-renders when filter changes
function SearchBar() {
const filter = useStore(state => state.filter);
const setFilter = useStore(state => state.setFilter);
return <input value={filter} onChange={e => setFilter(e.target.value)} />;
}
Zustand is straightforward. Components subscribe to specific slices and only re-render when those slices change. No boilerplate, no reducers, no provider wrapping.
Redux - Good for complex state with time-travel debugging
// userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUsers = createAsyncThunk(
'users/fetch',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/users');
return await response.json();
} catch (err) {
return rejectWithValue(err.message);
}
}
);
const userSlice = createSlice({
name: 'users',
initialState: {
items: [],
loading: false,
error: null,
lastFetch: null
},
reducers: {
userUpdated: (state, action) => {
const index = state.items.findIndex(u => u.id === action.payload.id);
if (index !== -1) {
state.items[index] = action.payload;
}
}
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
state.lastFetch = Date.now();
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
}
});
export const { userUpdated } = userSlice.actions;
export default userSlice.reducer;
Redux gives you predictable state updates, time-travel debugging, and middleware for logging/persistence. The trade-off is boilerplate. Use it when debugging complex state interactions is worth the ceremony.
When to Use What
- Local component state: Single component, no sharing needed
- Context API: Auth state, theme, settings that rarely change
- Zustand: Shared state that updates frequently, minimal boilerplate
- Redux: Complex apps where time-travel debugging and middleware matter
- Server state libraries (React Query, SWR): Data from APIs
Don’t use Redux for server state. React Query handles caching, refetching, and invalidation better.
2. Optimistic Updates and Conflict Resolution
Optimistic updates make apps feel fast. They also introduce race conditions and conflicts. You need strategies for both the happy path and the mess.
Basic Optimistic Update Pattern
// Using React Query
import { useMutation, useQueryClient } from 'react-query';
function useTodoUpdate() {
const queryClient = useQueryClient();
return useMutation(
(updatedTodo) => fetch(`/api/todos/${updatedTodo.id}`, {
method: 'PUT',
body: JSON.stringify(updatedTodo)
}),
{
// Before mutation runs
onMutate: async (updatedTodo) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries(['todos', updatedTodo.id]);
// Snapshot previous value for rollback
const previousTodo = queryClient.getQueryData(['todos', updatedTodo.id]);
// Optimistically update
queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo);
// Return context with snapshot
return { previousTodo };
},
// On error, rollback
onError: (err, updatedTodo, context) => {
queryClient.setQueryData(
['todos', updatedTodo.id],
context.previousTodo
);
},
// Always refetch after error or success
onSettled: (data, error, updatedTodo) => {
queryClient.invalidateQueries(['todos', updatedTodo.id]);
}
}
);
}
This pattern handles the happy path and rollback. It doesn’t handle conflicts when two users edit the same item.
Last-Write-Wins with Optimistic Locking
// Backend - Optimistic locking with version numbers
app.put('/api/todos/:id', async (req, res) => {
const { id } = req.params;
const { version, ...updates } = req.body;
const result = await db.query(
`UPDATE todos
SET title = $1, completed = $2, version = version + 1, updated_at = NOW()
WHERE id = $3 AND version = $4
RETURNING *`,
[updates.title, updates.completed, id, version]
);
if (result.rows.length === 0) {
// Version mismatch - someone else updated it
const current = await db.query('SELECT * FROM todos WHERE id = $1', [id]);
return res.status(409).json({
error: 'Conflict',
current: current.rows[0]
});
}
res.json(result.rows[0]);
});
// Frontend - Handle conflicts
function useTodoUpdateWithConflict() {
const queryClient = useQueryClient();
return useMutation(
(updatedTodo) => fetch(`/api/todos/${updatedTodo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTodo)
}).then(async res => {
if (res.status === 409) {
const conflict = await res.json();
throw new ConflictError(conflict.current);
}
return res.json();
}),
{
onMutate: async (updatedTodo) => {
await queryClient.cancelQueries(['todos', updatedTodo.id]);
const previousTodo = queryClient.getQueryData(['todos', updatedTodo.id]);
queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo);
return { previousTodo };
},
onError: (err, updatedTodo, context) => {
if (err instanceof ConflictError) {
// Show merge UI or force refresh
queryClient.setQueryData(['todos', updatedTodo.id], err.serverVersion);
showConflictDialog(context.previousTodo, err.serverVersion);
} else {
queryClient.setQueryData(['todos', updatedTodo.id], context.previousTodo);
}
}
}
);
}
class ConflictError extends Error {
constructor(serverVersion) {
super('Conflict detected');
this.serverVersion = serverVersion;
}
}
Version-based locking catches conflicts but requires user intervention. For collaborative editing, you need operational transformation or CRDTs.
Operational Transformation for Real-Time Collaboration
// Simplified OT for text editing (production use libraries like Yjs or ShareDB)
class TextOT {
// Transform operation A against operation B
// Returns transformed A that can be applied after B
static transform(opA, opB) {
if (opA.position < opB.position) {
return opA; // A comes before B, no change
}
if (opB.type === 'insert') {
// B inserted text before A's position
return {
...opA,
position: opA.position + opB.text.length
};
}
if (opB.type === 'delete') {
// B deleted text before A's position
return {
...opA,
position: Math.max(opA.position - opB.length, opB.position)
};
}
return opA;
}
}
// Client applies local operation immediately (optimistic)
function handleLocalEdit(operation) {
applyOperationToEditor(operation);
// Send to server
socket.emit('operation', {
docId: currentDocId,
operation,
version: localVersion
});
pendingOperations.push(operation);
}
// Server broadcasts to other clients
socket.on('operation', ({ operation, userId }) => {
if (userId === currentUserId) return; // Ignore our own ops
// Transform pending operations against incoming one
const transformedPending = pendingOperations.map(op =>
TextOT.transform(op, operation)
);
// Transform incoming operation against our pending ones
let transformedIncoming = operation;
for (const pending of pendingOperations) {
transformedIncoming = TextOT.transform(transformedIncoming, pending);
}
applyOperationToEditor(transformedIncoming);
pendingOperations = transformedPending;
});
Operational transformation is complex. Use libraries like Yjs, Automerge, or ShareDB for production collaborative editing. They handle the transform logic, conflict resolution, and edge cases you’ll spend months debugging.
3. Caching Strategies
Caching is invalidation. The hard part isn’t storing data, it’s knowing when to throw it away.
Multi-Layer Cache Strategy
// Frontend cache with React Query
import { QueryClient } from 'react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Cache for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep in cache for 10 minutes
cacheTime: 10 * 60 * 1000,
// Refetch on window focus for fresh data
refetchOnWindowFocus: true,
// Retry failed requests
retry: 2
}
}
});
// Backend cache with Redis
import Redis from 'ioredis';
const redis = new Redis();
async function getCachedUser(userId) {
const cacheKey = `user:${userId}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss - fetch from database
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (user.rows[0]) {
// Cache for 1 hour
await redis.setex(cacheKey, 3600, JSON.stringify(user.rows[0]));
}
return user.rows[0];
}
// Invalidate cache on update
async function updateUser(userId, updates) {
const result = await db.query(
'UPDATE users SET name = $1, email = $2 WHERE id = $3 RETURNING *',
[updates.name, updates.email, userId]
);
// Invalidate cache
await redis.del(`user:${userId}`);
return result.rows[0];
}
This gives you client-side caching (React Query) and server-side caching (Redis). Still need to handle cache invalidation across multiple servers.
Cache Invalidation Patterns
Time-based expiration - Simple but can serve stale data:
// Cache for fixed duration
await redis.setex('key', 300, value); // 5 minutes
Event-based invalidation - Accurate but requires coordination:
// Publisher (write path)
async function updateProduct(productId, updates) {
await db.query('UPDATE products SET ... WHERE id = $1', [productId]);
// Publish invalidation event
await redis.publish('cache:invalidate', JSON.stringify({
type: 'product',
id: productId
}));
}
// Subscriber (all app instances)
redis.subscribe('cache:invalidate');
redis.on('message', (channel, message) => {
const { type, id } = JSON.parse(message);
if (type === 'product') {
// Invalidate in local cache
localCache.delete(`product:${id}`);
// Invalidate in Redis
redis.del(`product:${id}`);
}
});
Write-through cache - Always consistent but slower writes:
async function updateProductWriteThrough(productId, updates) {
// Update database
const result = await db.query(
'UPDATE products SET ... WHERE id = $1 RETURNING *',
[productId]
);
// Update cache immediately
await redis.setex(
`product:${productId}`,
3600,
JSON.stringify(result.rows[0])
);
return result.rows[0];
}
Cache-aside with versioning - Handles race conditions:
async function getCachedWithVersion(key, fetchFn) {
const cached = await redis.get(key);
if (cached) {
const { version, data, timestamp } = JSON.parse(cached);
// Fetch new data in background if stale
if (Date.now() - timestamp > 60000) {
fetchAndUpdateCache(key, version, fetchFn);
}
return data;
}
return fetchAndUpdateCache(key, 0, fetchFn);
}
async function fetchAndUpdateCache(key, expectedVersion, fetchFn) {
const data = await fetchFn();
const version = expectedVersion + 1;
await redis.set(key, JSON.stringify({
version,
data,
timestamp: Date.now()
}));
return data;
}
4. Session Management
Sessions tie stateful interactions to users. They fail in interesting ways across server restarts, load balancers, and time zones.
JWT vs Server Sessions
JWT - Stateless but can’t be revoked easily
import jwt from 'jsonwebtoken';
// Login endpoint
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({ token, user });
});
// Middleware to verify JWT
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
}
JWTs work across servers without shared state. The problem: you can’t revoke them before expiration. If someone steals a token, it’s valid until it expires.
Server sessions with Redis - Stateful but revocable
import session from 'express-session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';
const redis = new Redis();
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
sameSite: 'strict'
}
}));
// Login
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Store user in session
req.session.userId = user.id;
req.session.email = user.email;
res.json({ user });
});
// Logout - actually destroys session
app.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.json({ success: true });
});
});
// Revoke all sessions for a user
async function revokeUserSessions(userId) {
const keys = await redis.keys('sess:*');
for (const key of keys) {
const session = await redis.get(key);
const data = JSON.parse(session);
if (data.userId === userId) {
await redis.del(key);
}
}
}
Server sessions let you revoke access immediately. Trade-off is Redis becomes a single point of failure (use Redis Sentinel or Cluster for production).
Hybrid Approach - JWT with Blacklist
// Short-lived JWT + Redis blacklist for revocation
const TOKEN_EXPIRY = '15m';
const REFRESH_EXPIRY = '7d';
app.post('/auth/login', async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password);
const accessToken = jwt.sign(
{ userId: user.id, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
process.env.JWT_SECRET,
{ expiresIn: REFRESH_EXPIRY }
);
// Store refresh token in Redis for revocation
await redis.setex(
`refresh:${user.id}:${refreshToken}`,
7 * 24 * 60 * 60,
'valid'
);
res.json({ accessToken, refreshToken, user });
});
// Refresh endpoint
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
if (decoded.type !== 'refresh') {
return res.status(401).json({ error: 'Invalid token type' });
}
// Check if token is blacklisted
const valid = await redis.get(`refresh:${decoded.userId}:${refreshToken}`);
if (!valid) {
return res.status(401).json({ error: 'Token revoked' });
}
// Issue new access token
const accessToken = jwt.sign(
{ userId: decoded.userId, type: 'access' },
process.env.JWT_SECRET,
{ expiresIn: TOKEN_EXPIRY }
);
res.json({ accessToken });
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Logout - blacklist refresh token
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET);
await redis.del(`refresh:${decoded.userId}:${refreshToken}`);
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: 'Invalid token' });
}
});
This gives you stateless access tokens (fast) with revocable refresh tokens (secure). Access tokens are short-lived so stolen tokens expire quickly.
5. State Synchronization for Real-Time Features
Real-time synchronization means handling out-of-order messages, reconnection, and keeping state consistent across clients.
WebSocket State Sync Pattern
// Server - Broadcasting state changes
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
// Track connections per document
const documentConnections = new Map();
wss.on('connection', (ws, req) => {
let currentDocId = null;
ws.on('message', async (message) => {
const data = JSON.parse(message);
if (data.type === 'subscribe') {
currentDocId = data.docId;
if (!documentConnections.has(currentDocId)) {
documentConnections.set(currentDocId, new Set());
}
documentConnections.get(currentDocId).add(ws);
// Send current state
const doc = await getDocument(currentDocId);
ws.send(JSON.stringify({
type: 'snapshot',
data: doc
}));
}
if (data.type === 'update') {
// Apply update to database
await updateDocument(data.docId, data.changes);
// Broadcast to all subscribers except sender
const subscribers = documentConnections.get(data.docId) || new Set();
subscribers.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'update',
changes: data.changes,
userId: data.userId
}));
}
});
}
});
ws.on('close', () => {
if (currentDocId) {
documentConnections.get(currentDocId)?.delete(ws);
}
});
});
Client - Handling reconnection and message ordering
class DocumentSync {
constructor(docId, userId) {
this.docId = docId;
this.userId = userId;
this.ws = null;
this.messageQueue = [];
this.connected = false;
this.reconnectAttempts = 0;
this.connect();
}
connect() {
this.ws = new WebSocket('ws://localhost:8080');
this.ws.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
// Subscribe to document
this.send({
type: 'subscribe',
docId: this.docId
});
// Send queued messages
while (this.messageQueue.length > 0) {
const msg = this.messageQueue.shift();
this.ws.send(JSON.stringify(msg));
}
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'snapshot') {
// Initial state
this.handleSnapshot(data.data);
}
if (data.type === 'update') {
// Remote update
this.handleRemoteUpdate(data.changes, data.userId);
}
};
this.ws.onclose = () => {
this.connected = false;
this.reconnect();
};
this.ws.onerror = () => {
this.ws.close();
};
}
reconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => this.connect(), delay);
}
send(message) {
if (this.connected) {
this.ws.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
}
}
localUpdate(changes) {
// Apply optimistically
this.applyChanges(changes);
// Send to server
this.send({
type: 'update',
docId: this.docId,
userId: this.userId,
changes
});
}
handleSnapshot(data) {
// Replace local state with server snapshot
this.setState(data);
}
handleRemoteUpdate(changes, userId) {
// Apply changes from other users
if (userId !== this.userId) {
this.applyChanges(changes);
}
}
applyChanges(changes) {
// Implementation depends on your state structure
}
setState(data) {
// Implementation depends on your state structure
}
}
This handles reconnection with exponential backoff and queues messages while disconnected. Production systems need message IDs to detect duplicates and handle out-of-order delivery.
6. Offline-First Patterns
Offline-first means the app works without network, then syncs when connection returns. This requires local storage, conflict resolution, and sync strategies.
Service Worker Cache Strategy
// service-worker.js
const CACHE_NAME = 'app-v1';
const OFFLINE_URL = '/offline.html';
// Install - cache essential assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/offline.html',
'/app.js',
'/styles.css',
'/manifest.json'
]);
})
);
});
// Fetch - network first, fall back to cache
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_URL);
})
);
return;
}
// API requests - network only with offline indicator
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request).catch(() => {
return new Response(JSON.stringify({ offline: true }), {
headers: { 'Content-Type': 'application/json' }
});
})
);
return;
}
// Static assets - cache first
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
Local-First Data with IndexedDB
// db.js - IndexedDB wrapper
class LocalDB {
constructor() {
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('AppDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object stores
const todos = db.createObjectStore('todos', { keyPath: 'id' });
todos.createIndex('syncStatus', 'syncStatus');
todos.createIndex('updatedAt', 'updatedAt');
};
});
}
async saveTodo(todo) {
const tx = this.db.transaction('todos', 'readwrite');
const store = tx.objectStore('todos');
await store.put({
...todo,
syncStatus: 'pending',
updatedAt: Date.now()
});
}
async getTodos() {
const tx = this.db.transaction('todos', 'readonly');
const store = tx.objectStore('todos');
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getPendingSync() {
const tx = this.db.transaction('todos', 'readonly');
const store = tx.objectStore('todos');
const index = store.index('syncStatus');
return new Promise((resolve, reject) => {
const request = index.getAll('pending');
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async markSynced(id) {
const tx = this.db.transaction('todos', 'readwrite');
const store = tx.objectStore('todos');
const todo = await store.get(id);
if (todo) {
todo.syncStatus = 'synced';
await store.put(todo);
}
}
}
Background Sync for Offline Actions
// Register background sync
async function saveOffline(todo) {
await localDB.saveTodo(todo);
if ('sync' in registration) {
// Request background sync
await registration.sync.register('sync-todos');
} else {
// Fallback - sync immediately
await syncTodos();
}
}
// Service worker - handle sync event
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-todos') {
event.waitUntil(syncTodos());
}
});
async function syncTodos() {
const db = new LocalDB();
await db.init();
const pending = await db.getPendingSync();
for (const todo of pending) {
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo)
});
if (response.ok) {
await db.markSynced(todo.id);
}
} catch (err) {
// Will retry on next sync
console.error('Sync failed for todo', todo.id, err);
}
}
}
Background Sync API runs even after the user closes the tab. The browser retries failed syncs with exponential backoff. This works for Android and recent Chrome versions. iOS doesn’t support it - you need to sync when the app opens.
Conflict Resolution for Offline Edits
// Client - Track local and server versions
async function syncWithConflictResolution() {
const pending = await localDB.getPendingSync();
for (const localTodo of pending) {
try {
const response = await fetch(`/api/todos/${localTodo.id}`);
const serverTodo = await response.json();
if (serverTodo.updatedAt > localTodo.serverUpdatedAt) {
// Server has newer version - conflict!
const resolution = await resolveConflict(localTodo, serverTodo);
if (resolution === 'keep-local') {
await pushToServer(localTodo);
} else if (resolution === 'keep-server') {
await localDB.saveTodo({ ...serverTodo, syncStatus: 'synced' });
} else if (resolution === 'merge') {
const merged = await mergeConflict(localTodo, serverTodo);
await pushToServer(merged);
}
} else {
// No conflict - push our changes
await pushToServer(localTodo);
}
} catch (err) {
console.error('Sync failed', err);
}
}
}
async function resolveConflict(local, server) {
// Show UI for user to resolve
return new Promise((resolve) => {
showConflictDialog({
local,
server,
onResolve: (choice) => resolve(choice)
});
});
}
Automatic conflict resolution works for some data (timestamps, counters). For user-generated content, you usually need to ask the user to decide.
7. State Machines for Complex Workflows
State machines make complex flows predictable. They prevent invalid states like “loading and error at the same time.”
XState for Robust State Management
import { createMachine, interpret } from 'xstate';
const paymentMachine = createMachine({
id: 'payment',
initial: 'idle',
context: {
amount: 0,
paymentMethod: null,
error: null
},
states: {
idle: {
on: {
START_PAYMENT: {
target: 'validating',
actions: 'setPaymentDetails'
}
}
},
validating: {
invoke: {
src: 'validatePayment',
onDone: {
target: 'processing',
actions: 'clearError'
},
onError: {
target: 'error',
actions: 'setError'
}
}
},
processing: {
invoke: {
src: 'processPayment',
onDone: {
target: 'success',
actions: 'saveReceipt'
},
onError: {
target: 'error',
actions: 'setError'
}
}
},
success: {
type: 'final'
},
error: {
on: {
RETRY: 'validating',
CANCEL: 'idle'
}
}
}
}, {
actions: {
setPaymentDetails: (context, event) => {
context.amount = event.amount;
context.paymentMethod = event.paymentMethod;
},
setError: (context, event) => {
context.error = event.data;
},
clearError: (context) => {
context.error = null;
},
saveReceipt: (context, event) => {
context.receipt = event.data;
}
},
services: {
validatePayment: async (context) => {
if (context.amount <= 0) {
throw new Error('Invalid amount');
}
if (!context.paymentMethod) {
throw new Error('No payment method');
}
return true;
},
processPayment: async (context) => {
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({
amount: context.amount,
paymentMethod: context.paymentMethod
})
});
if (!response.ok) {
throw new Error('Payment failed');
}
return response.json();
}
}
});
// Use in React
import { useMachine } from '@xstate/react';
function PaymentForm() {
const [state, send] = useMachine(paymentMachine);
const handleSubmit = (amount, paymentMethod) => {
send({ type: 'START_PAYMENT', amount, paymentMethod });
};
if (state.matches('success')) {
return <div>Payment successful!</div>;
}
if (state.matches('error')) {
return (
<div>
<p>Error: {state.context.error.message}</p>
<button onClick={() => send('RETRY')}>Retry</button>
<button onClick={() => send('CANCEL')}>Cancel</button>
</div>
);
}
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit(100, 'card');
}}>
<button disabled={state.matches('validating') || state.matches('processing')}>
{state.matches('processing') ? 'Processing...' : 'Pay'}
</button>
</form>
);
}
State machines eliminate impossible states. You can’t be both loading and showing an error. You can visualize the entire flow. Testing becomes checking transitions.
8. Race Condition Prevention
Race conditions happen when multiple operations access shared state. The fixes involve ordering, locking, or eliminating shared state.
Request Cancellation Pattern
// React - Cancel previous requests
import { useEffect, useState } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const abortController = new AbortController();
async function search() {
if (!query) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortController.signal
});
const data = await response.json();
setResults(data);
} catch (err) {
if (err.name === 'AbortError') {
// Request was cancelled - this is fine
return;
}
console.error('Search failed', err);
} finally {
setLoading(false);
}
}
search();
// Cleanup - cancel request if query changes
return () => {
abortController.abort();
};
}, [query]);
return <div>...</div>;
}
AbortController cancels in-flight requests when inputs change. Without this, fast typing causes results to arrive out of order. User types “cat”, then “cats” - but “cat” results arrive last and overwrite “cats” results.
Debouncing and Request Deduplication
// Debounce to reduce requests
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function SearchWithDebounce() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// Only fires 300ms after user stops typing
fetch(`/api/search?q=${debouncedQuery}`);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
Debouncing prevents excessive requests. Still need cancellation for when debounced value changes before request completes.
Database-Level Locking
// PostgreSQL - Row-level locking
app.post('/api/transfer', async (req, res) => {
const { fromAccount, toAccount, amount } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Lock rows in consistent order to prevent deadlocks
const accountIds = [fromAccount, toAccount].sort();
const accounts = await client.query(
'SELECT * FROM accounts WHERE id = ANY($1) FOR UPDATE',
[accountIds]
);
const from = accounts.rows.find(a => a.id === fromAccount);
const to = accounts.rows.find(a => a.id === toAccount);
if (from.balance < amount) {
throw new Error('Insufficient funds');
}
await client.query(
'UPDATE accounts SET balance = balance - $1 WHERE id = $2',
[amount, fromAccount]
);
await client.query(
'UPDATE accounts SET balance = balance + $1 WHERE id = $2',
[amount, toAccount]
);
await client.query('COMMIT');
res.json({ success: true });
} catch (err) {
await client.query('ROLLBACK');
res.status(400).json({ error: err.message });
} finally {
client.release();
}
});
FOR UPDATE locks rows until the transaction completes. Sorting account IDs prevents deadlocks when two transfers happen in opposite directions simultaneously.
9. State Persistence and Hydration
Hydration means loading server state into client without flicker or mismatch. It fails when server and client render different content.
SSR with State Hydration
// Server - Next.js getServerSideProps
export async function getServerSideProps(context) {
const todos = await db.query('SELECT * FROM todos WHERE user_id = $1', [
context.req.session.userId
]);
return {
props: {
initialTodos: todos.rows
}
};
}
// Client - Hydrate React Query
import { QueryClient, QueryClientProvider, Hydrate } from 'react-query';
function MyApp({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
);
}
// Page component
function TodoList({ initialTodos }) {
const { data: todos } = useQuery(
'todos',
fetchTodos,
{
// Use server data initially, refetch in background
initialData: initialTodos,
staleTime: 0 // Consider server data stale immediately
}
);
return <div>{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}</div>;
}
Server renders with initial data, client hydrates and continues from there. Set staleTime: 0 to refetch in background for fresh data.
Local Storage Persistence
// Persist Zustand store to localStorage
import create from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
preferences: {
theme: 'light',
language: 'en'
},
setTheme: (theme) => set((state) => ({
preferences: { ...state.preferences, theme }
})),
setLanguage: (language) => set((state) => ({
preferences: { ...state.preferences, language }
}))
}),
{
name: 'app-preferences',
// Only persist preferences, not derived state
partialize: (state) => ({ preferences: state.preferences })
}
)
);
Persist preferences and draft content. Don’t persist server data - that goes stale.
10. Common Mistakes
Prop Drilling Through Six Levels
// Bad - passing props through every level
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} setUser={setUser} />;
}
function Layout({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} />;
}
function Sidebar({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} />;
}
function UserMenu({ user, setUser }) {
return <div>{user.name}</div>;
}
// Good - context or state management
const UserContext = createContext();
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout />
</UserContext.Provider>
);
}
function UserMenu() {
const { user } = useContext(UserContext);
return <div>{user.name}</div>;
}
Everything in Global State
// Bad - modal state doesn't need to be global
const useStore = create((set) => ({
user: null,
todos: [],
isModalOpen: false, // This should be local!
modalContent: null,
openModal: (content) => set({ isModalOpen: true, modalContent: content })
}));
// Good - local state for UI
function TodoItem({ todo }) {
const [isEditModalOpen, setEditModalOpen] = useState(false);
return (
<>
<button onClick={() => setEditModalOpen(true)}>Edit</button>
{isEditModalOpen && <EditModal todo={todo} onClose={() => setEditModalOpen(false)} />}
</>
);
}
Use local state for UI that doesn’t need sharing. Global state for auth, server data, and cross-component coordination.
Stale Closures in Effects
// Bad - count is stale inside interval
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // Always logs 0
setCount(count + 1); // Always sets to 1
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps - interval never updates
return <div>{count}</div>;
}
// Good - use functional update
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1); // Uses current value
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>{count}</div>;
}
Functional updates access current state without capturing it in closure.
Ignoring Cache Invalidation
// Bad - cache never updates after mutation
function TodoList() {
const { data: todos } = useQuery('todos', fetchTodos);
const addTodo = async (text) => {
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
});
// Todo added but list doesn't update!
};
return <div>...</div>;
}
// Good - invalidate cache after mutation
function TodoList() {
const queryClient = useQueryClient();
const { data: todos } = useQuery('todos', fetchTodos);
const addTodo = async (text) => {
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
});
// Refetch todos
queryClient.invalidateQueries('todos');
};
return <div>...</div>;
}
Always invalidate or update cache after mutations. Otherwise users see stale data until manual refresh.
Key Takeaways
-
Choose state management based on sharing needs and update frequency - Context for rare updates, Zustand for simple cases, Redux for complex debugging needs
-
Optimistic updates need rollback strategies - Save previous state, handle conflicts, show clear error states
-
Multi-layer caching is standard - Client cache (React Query), server cache (Redis), CDN cache. Each layer needs invalidation strategy
-
Sessions can’t be stateless and revocable - Choose JWTs for stateless, server sessions for revocation, or hybrid with refresh tokens
-
Real-time sync requires conflict resolution - Operational transformation for collaborative editing, version numbers for simple cases, CRDTs for distributed systems
-
Offline-first needs local storage and background sync - IndexedDB for data, service workers for sync, conflict resolution UI for user decisions
-
State machines prevent impossible states - Can’t be loading and error simultaneously, explicit transitions, visualizable flows
-
Race conditions hide in async operations - Cancel stale requests, debounce inputs, lock database rows, order operations consistently
-
Hydration fails on client/server mismatch - Same initial data, same render logic, refetch in background for freshness
-
Most state should be local - Global state for sharing across components, local state for UI, server state in query libraries
State management complexity grows with your application. Start simple, add layers as needed, and always have a strategy for when things go wrong - because they will.