| name | state-management-patterns |
| description | Explains state management in web applications including client state, server state, URL state, and caching strategies. Use when discussing where to store state, choosing between local and global state, or implementing data fetching patterns. |
State Management Patterns
Overview
State is data that changes over time. Where and how you manage state significantly impacts application architecture, performance, and complexity.
Types of State
| Type | Examples | Characteristics |
|---|
| UI State | Modal open, tab selected, form input | Local, ephemeral |
| Server State | User data, products, posts | Remote, cached, async |
| URL State | Page, filters, search query | Shareable, bookmarkable |
| Browser State | localStorage, sessionStorage, cookies | Persistent, limited |
| Application State | Auth, theme, user preferences | Global, session-scoped |
Client State vs Server State
Client State
Data that exists only in the browser:
const [isOpen, setIsOpen] = useState(false);
const [theme, setTheme] = useContext(ThemeContext);
Server State
Data fetched from a server:
const { data, loading, error } = useQuery('/api/products');
State Colocation
Place state as close as possible to where it's used.
Component State (Local)
function Toggle() {
const [on, setOn] = useState(false);
return <button onClick={() => setOn(!on)}>{on ? 'On' : 'Off'}</button>;
}
Lifted State (Shared by Siblings)
function Form() {
const [value, setValue] = useState('');
return (
<>
<Input value={value} onChange={setValue} />
<Preview value={value} />
</>
);
}
Context (Deep Tree Access)
<ThemeProvider value={theme}>
<App /> {}
</ThemeProvider>
Global State (Application-Wide)
Server State Management
The Problem
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
}
Server State Libraries
Libraries like TanStack Query, SWR, Apollo handle:
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 60000,
});
}
Benefits:
- Automatic caching and deduplication
- Background refetching
- Loading and error states
- Cache invalidation
- Optimistic updates
URL State
State that should survive refresh and be shareable.
When to Use URL State
- Pagination:
/products?page=2
- Filters:
/products?category=shoes&size=10
- Search:
/search?q=query
- Tabs/views:
/dashboard?view=analytics
- Modal content:
/products/123 with modal
Managing URL State
const searchParams = useSearchParams();
const page = searchParams.get('page') || '1';
function setPage(newPage) {
const params = new URLSearchParams(searchParams);
params.set('page', newPage);
router.push(`?${params.toString()}`);
}
URL vs Local State
| State | URL | Local |
|---|
| Current search query | ✓ | |
| Search input (typing) | | ✓ |
| Selected filters | ✓ | |
| Filter dropdown open | | ✓ |
| Current page | ✓ | |
| Scroll position | | ✓ |
Browser Storage
Comparison
| Storage | Size | Persistence | Access |
|---|
| localStorage | ~5MB | Until cleared | Same origin |
| sessionStorage | ~5MB | Tab session | Same tab |
| Cookies | ~4KB | Configurable | Sent to server |
| IndexedDB | Large | Until cleared | Same origin |
Common Patterns
const theme = localStorage.getItem('theme') || 'light';
const draftId = sessionStorage.getItem('draft-id');
Server Components and State
React Server Components
async function ProductPage({ id }) {
const product = await db.products.findById(id);
return <ProductDetails product={product} />;
}
'use client';
function AddToCart({ productId }) {
const [quantity, setQuantity] = useState(1);
return <button onClick={() => addToCart(productId, quantity)}>Add</button>;
}
Server State is Simpler
Server Component Approach:
Database → Server Component → HTML to client
(No loading states needed, data already there)
Client Fetch Approach:
Server Component → Client Component → Fetch → Loading → Data → Render
(More complexity, but necessary for interactivity)
Caching Strategies
Cache-First (Stale-While-Revalidate)
const { data } = useQuery({
queryKey: ['products'],
staleTime: 60000,
cacheTime: 300000,
});
Network-First
const { data } = useQuery({
queryKey: ['products'],
staleTime: 0,
networkMode: 'always',
});
Cache Invalidation
const mutation = useMutation({
mutationFn: updateProduct,
onSuccess: () => {
queryClient.invalidateQueries(['products']);
},
});
State Machine Patterns
For complex state transitions:
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [state, setState] = useState('idle');
Anti-Patterns
1. Prop Drilling
<App user={user}>
<Layout user={user}>
<Page user={user}>
<Header user={user}>
<Avatar user={user} />
// Good: Use context for deep access
<UserContext.Provider value={user}>
<App />
2. Global State for Local Needs
dispatch({ type: 'SET_MODAL_OPEN', payload: true });
const [isOpen, setIsOpen] = useState(false);
3. Duplicating Server State
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
const { data: products } = useQuery(['products'], fetchProducts);
4. Not Considering URL State
const [filters, setFilters] = useState({ category: 'all' });
const filters = Object.fromEntries(useSearchParams());
Decision Framework
Is this UI state (ephemeral, local)?
├── Yes → useState in component
│
└── No → Is it shared between siblings?
├── Yes → Lift state to parent
│
└── No → Is it needed deep in tree?
├── Yes → Context or global state
│
└── No → Is it from a server?
├── Yes → Server state library
│
└── No → Should it survive refresh?
├── Yes → URL or localStorage
└── No → Component state
Deep Dive: Understanding State From First Principles
What Is State, Really?
State is any data that changes over time and affects what the user sees or can do. Understanding this precisely helps you manage it correctly:
let count = 0;
button.onclick = () => {
count++;
display.textContent = count;
};
let inputValue = '';
let products = null;
const API_URL = 'https://api.example.com';
const TAX_RATE = 0.08;
The Reactivity Problem
Why do frameworks exist? Because keeping UI in sync with state is HARD:
const state = {
user: null,
posts: [],
selectedPost: null,
isLoading: false,
};
function setUser(user) {
state.user = user;
document.getElementById('user-name').textContent = user.name;
document.getElementById('user-avatar').src = user.avatar;
document.getElementById('welcome-message').textContent = `Welcome, ${user.name}`;
}
function setPosts(posts) {
state.posts = posts;
const container = document.getElementById('posts');
container.innerHTML = '';
posts.forEach(post => {
const el = document.createElement('div');
el.textContent = post.title;
el.onclick = () => setSelectedPost(post);
container.appendChild(el);
});
}
The framework solution:
function App() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
return (
<div>
{user && <span>Welcome, {user.name}</span>}
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
Memory Model: Where State Lives
Understanding memory helps you understand state:
BROWSER MEMORY MODEL:
┌─────────────────────────────────────────────────────────────────┐
│ JavaScript Heap │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Component State │ │ Closure Scopes │ │
│ │ (useState, etc.) │ │ (functions, refs) │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Module Scope │ │ Global Variables │ │
│ │ (imports, consts) │ │ (window.*) │ │
│ └──────────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Browser Storage │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐ │
│ │localStorage│ │sessionStor.│ │ Cookies │ │ IndexedDB │ │
│ │ (~5MB) │ │ (~5MB) │ │ (~4KB) │ │ (large) │ │
│ └────────────┘ └────────────┘ └────────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
JavaScript Heap: Cleared on page reload
Browser Storage: Persists across reloads (until cleared)
State lifecycle in SPAs:
const [count, setCount] = useState(0);
setCount(1);
State Updates: Synchronous vs Asynchronous
React batches state updates for performance:
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
function handleClick() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
}
Immutability: Why It Matters
React uses reference equality to detect changes:
const [user, setUser] = useState({ name: 'John', age: 30 });
function birthday() {
user.age = 31;
setUser(user);
}
function birthday() {
setUser({ ...user, age: 31 });
}
const [state, setState] = useState({
user: {
profile: {
name: 'John',
settings: { theme: 'dark' }
}
}
});
setState({
...state,
user: {
...state.user,
profile: {
...state.user.profile,
settings: {
...state.user.profile.settings,
theme: 'light'
}
}
}
});
setState(produce(state, draft => {
draft.user.profile.settings.theme = 'light';
}));
Context: How It Actually Works
React Context creates a "wormhole" through the component tree:
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<UserMenu user={user} /> // Finally used here!
</Sidebar>
</Layout>
</App>
const UserContext = createContext(null);
<UserContext.Provider value={user}>
<App>
<Layout>
<Sidebar>
<UserMenu /> // Can access user directly!
</Sidebar>
</Layout>
</App>
</UserContext.Provider>
function UserMenu() {
const user = useContext(UserContext);
return <span>{user.name}</span>;
}
Context performance gotcha:
const AppContext = createContext();
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
return (
<AppContext.Provider value={{ user, theme, notifications }}>
<Header /> {/* Re-renders when ANYTHING changes */}
<Main /> {/* Re-renders when ANYTHING changes */}
<Footer /> {/* Re-renders when ANYTHING changes */}
</AppContext.Provider>
);
}
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<NotificationContext.Provider value={notifications}>
<App />
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
Server State: A Different Beast
Server state has fundamentally different characteristics:
const [isOpen, setIsOpen] = useState(false);
const [products, setProducts] = useState(null);
useEffect(() => {
fetch('/api/products').then(r => r.json()).then(setProducts);
}, []);
Why server state libraries exist:
function ProductList() {
const [products, setProducts] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [lastFetched, setLastFetched] = useState(null);
const fetchProducts = async () => {
setIsLoading(true);
setError(null);
try {
const res = await fetch('/api/products');
if (!res.ok) throw new Error('Failed');
const data = await res.json();
setProducts(data);
setLastFetched(Date.now());
} catch (e) {
setError(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchProducts();
}, []);
}
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 60000,
refetchOnWindowFocus: true,
retry: 3,
});
}
URL State: The Serialization Challenge
URL state must be serializable to a string:
{
category: 'shoes',
sizes: [7, 8, 9],
page: 2,
sort: { field: 'price', order: 'asc' }
}
import { useSearchParams } from 'next/navigation';
function ProductsPage() {
const searchParams = useSearchParams();
const category = searchParams.get('category');
const page = parseInt(searchParams.get('page') || '1');
const sizes = searchParams.get('sizes')?.split(',').map(Number) || [];
function setFilters(newFilters) {
const params = new URLSearchParams();
if (newFilters.category) params.set('category', newFilters.category);
if (newFilters.page > 1) params.set('page', String(newFilters.page));
if (newFilters.sizes.length) params.set('sizes', newFilters.sizes.join(','));
router.push(`/products?${params.toString()}`);
}
}
State Machines: Eliminating Impossible States
Complex state often has implicit rules:
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [data, setData] = useState(null);
async function fetchData() {
setIsLoading(true);
setIsError(false);
setIsSuccess(false);
try {
const result = await fetch('/api/data');
setData(result);
setIsSuccess(true);
} catch (e) {
setIsError(true);
} finally {
setIsLoading(false);
}
}
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success', data: Data }
| { status: 'error', error: Error };
const [state, setState] = useState<State>({ status: 'idle' });
async function fetchData() {
setState({ status: 'loading' });
try {
const data = await fetch('/api/data');
setState({ status: 'success', data });
} catch (error) {
setState({ status: 'error', error });
}
}
switch (state.status) {
case 'idle': return <button onClick={fetchData}>Load</button>;
case 'loading': return <Spinner />;
case 'success': return <DataView data={state.data} />;
case 'error': return <Error message={state.error.message} />;
}
The Single Source of Truth Principle
Every piece of state should have ONE authoritative location:
const userContext = { user: { name: 'John' } };
const [userData, setUserData] = useState({ name: 'John' });
const [formName, setFormName] = useState('John');
const { user, updateUser } = useUserContext();
const [draft, setDraft] = useState(user.name);
function handleSave() {
updateUser({ ...user, name: draft });
}
function Header() {
const { user } = useUserContext();
return <span>{user.name}</span>;
}
For Framework Authors: Building State Management Systems
Implementation Note: The patterns and code examples below represent one proven approach to building state management systems. Reactivity can be implemented many ways—Vue uses proxies, Solid uses signals, Svelte uses compile-time reactivity, and React uses explicit setState. The direction shown here (signals-based) is increasingly popular but not the only valid approach. Choose based on your framework's rendering model, bundle size goals, and developer ergonomics.
Implementing a Reactivity System
let currentObserver = null;
let batchedUpdates = [];
let isBatching = false;
function createSignal(initialValue) {
let value = initialValue;
const subscribers = new Set();
function read() {
if (currentObserver) {
subscribers.add(currentObserver);
}
return value;
}
function write(newValue) {
if (value !== newValue) {
value = newValue;
if (isBatching) {
subscribers.forEach(s => {
if (!batchedUpdates.includes(s)) {
batchedUpdates.push(s);
}
});
} else {
subscribers.forEach(s => s());
}
}
}
return [read, write];
}
function createEffect(fn) {
const effect = () => {
currentObserver = effect;
try {
fn();
} finally {
currentObserver = null;
}
};
effect();
}
function createMemo(fn) {
const [value, setValue] = createSignal(undefined);
createEffect(() => setValue(fn()));
return value;
}
function batch(fn) {
isBatching = true;
try {
fn();
} finally {
isBatching = false;
const updates = [...new Set(batchedUpdates)];
batchedUpdates = [];
updates.forEach(u => u());
}
}
const [count, setCount] = createSignal(0);
const [name, setName] = createSignal('World');
const greeting = createMemo(() => `Hello, ${name()}! Count: ${count()}`);
createEffect(() => {
console.log(greeting());
});
batch(() => {
setCount(1);
setName('User');
});
Building a Store System
function createStore(initialState) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function setState(updater) {
const nextState = typeof updater === 'function'
? updater(state)
: updater;
if (typeof nextState === 'object' && !Array.isArray(nextState)) {
state = { ...state, ...nextState };
} else {
state = nextState;
}
listeners.forEach(listener => listener(state));
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function subscribeWithSelector(selector, listener) {
let currentSelection = selector(state);
return subscribe((newState) => {
const newSelection = selector(newState);
if (!Object.is(currentSelection, newSelection)) {
currentSelection = newSelection;
listener(newSelection);
}
});
}
return { getState, setState, subscribe, subscribeWithSelector };
}
function useStore(store, selector = s => s) {
const [state, setState] = useState(() => selector(store.getState()));
useEffect(() => {
return store.subscribeWithSelector(selector, setState);
}, [store, selector]);
return state;
}
function applyMiddleware(...middlewares) {
return (createStore) => (initialState) => {
const store = createStore(initialState);
let dispatch = store.setState;
const api = {
getState: store.getState,
dispatch: (action) => dispatch(action),
};
const chain = middlewares.map(mw => mw(api));
dispatch = compose(...chain)(store.setState);
return { ...store, setState: dispatch };
};
}
const logger = (api) => (next) => (action) => {
console.log('Before:', api.getState());
next(action);
console.log('After:', api.getState());
};
Implementing Context System
const contextMap = new Map();
function createContext(defaultValue) {
const id = Symbol('context');
function Provider({ value, children }) {
const prevValue = contextMap.get(id);
contextMap.set(id, value);
try {
return children;
} finally {
if (prevValue !== undefined) {
contextMap.set(id, prevValue);
} else {
contextMap.delete(id);
}
}
}
function useContext() {
return contextMap.has(id) ? contextMap.get(id) : defaultValue;
}
return { Provider, useContext, id };
}
class ContextScope {
constructor(parent = null) {
this.values = new Map();
this.parent = parent;
}
provide(context, value) {
this.values.set(context.id, value);
}
consume(context) {
if (this.values.has(context.id)) {
return this.values.get(context.id);
}
if (this.parent) {
return this.parent.consume(context);
}
return context.defaultValue;
}
fork() {
return new ContextScope(this);
}
}
import { AsyncLocalStorage } from 'async_hooks';
const asyncContext = new AsyncLocalStorage();
function runWithContext(context, fn) {
return asyncContext.run(context, fn);
}
function getCurrentContext() {
return asyncContext.getStore();
}
Server State Cache Implementation
class QueryClient {
constructor() {
this.cache = new Map();
this.subscribers = new Map();
this.defaultOptions = {
staleTime: 0,
cacheTime: 5 * 60 * 1000,
retry: 3,
};
}
getQueryData(key) {
const keyStr = JSON.stringify(key);
return this.cache.get(keyStr)?.data;
}
setQueryData(key, updater) {
const keyStr = JSON.stringify(key);
const current = this.cache.get(keyStr);
const newData = typeof updater === 'function'
? updater(current?.data)
: updater;
this.cache.set(keyStr, {
data: newData,
timestamp: Date.now(),
status: 'success',
});
this.notifySubscribers(keyStr);
}
invalidateQueries(key) {
const keyStr = JSON.stringify(key);
const entry = this.cache.get(keyStr);
if (entry) {
entry.isStale = true;
this.notifySubscribers(keyStr);
}
}
subscribe(key, callback) {
const keyStr = JSON.stringify(key);
if (!this.subscribers.has(keyStr)) {
this.subscribers.set(keyStr, new Set());
}
this.subscribers.get(keyStr).add(callback);
return () => {
this.subscribers.get(keyStr)?.delete(callback);
};
}
notifySubscribers(keyStr) {
this.subscribers.get(keyStr)?.forEach(cb => cb());
}
}
function useQuery(key, queryFn, options = {}) {
const client = useQueryClient();
const [state, setState] = useState(() => {
const cached = client.getQueryData(key);
return {
data: cached,
isLoading: !cached,
error: null,
isStale: true,
};
});
useEffect(() => {
const keyStr = JSON.stringify(key);
let cancelled = false;
async function fetch() {
setState(s => ({ ...s, isLoading: true }));
try {
const data = await queryFn();
if (!cancelled) {
client.setQueryData(key, data);
setState({ data, isLoading: false, error: null, isStale: false });
}
} catch (error) {
if (!cancelled) {
setState(s => ({ ...s, isLoading: false, error }));
}
}
}
const cached = client.cache.get(keyStr);
const isStale = !cached ||
Date.now() - cached.timestamp > (options.staleTime || 0);
if (isStale) {
fetch();
}
const unsubscribe = client.subscribe(key, fetch);
return () => {
cancelled = true;
unsubscribe();
};
}, [JSON.stringify(key)]);
return state;
}
URL State Synchronization
function createURLState(schema) {
function parse() {
const params = new URLSearchParams(window.location.search);
const state = {};
for (const [key, config] of Object.entries(schema)) {
const value = params.get(key);
if (value === null) {
state[key] = config.default;
} else {
switch (config.type) {
case 'number':
state[key] = Number(value);
break;
case 'boolean':
state[key] = value === 'true';
break;
case 'array':
state[key] = value.split(',');
break;
default:
state[key] = value;
}
}
}
return state;
}
function stringify(state) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(state)) {
const config = schema[key];
if (value === config?.default) continue;
if (Array.isArray(value)) {
params.set(key, value.join(','));
} else if (value !== undefined && value !== null) {
params.set(key, String(value));
}
}
return params.toString();
}
function update(newState, options = { replace: false }) {
const merged = { ...parse(), ...newState };
const queryString = stringify(merged);
const url = queryString
? `${window.location.pathname}?${queryString}`
: window.location.pathname;
if (options.replace) {
history.replaceState(null, '', url);
} else {
history.pushState(null, '', url);
}
window.dispatchEvent(new CustomEvent('urlstatechange', { detail: merged }));
}
return { parse, stringify, update };
}
function useURLState(schema) {
const urlState = useMemo(() => createURLState(schema), []);
const [state, setState] = useState(urlState.parse);
useEffect(() => {
const handler = () => setState(urlState.parse());
window.addEventListener('popstate', handler);
window.addEventListener('urlstatechange', handler);
return () => {
window.removeEventListener('popstate', handler);
window.removeEventListener('urlstatechange', handler);
};
}, []);
const setURLState = useCallback((updates) => {
urlState.update(updates);
}, []);
return [state, setURLState];
}
Related Skills