Choose and implement the right state management pattern for your frontend application. Covers local state, lifted state, context, external stores (Redux, Zustand, Jotai), server state management, and when each approach makes sense.
State management is the discipline of coordinating data across components, views, and user interactions in a frontend application. It matters when a user’s actions — form inputs, navigation, asynchronous responses, or real-time updates — must be reflected consistently and efficiently across the UI. This page details the patterns, techniques, and trade-offs for managing state, from simple local state to distributed, server-synchronized state, with a focus on clarity, correctness, and maintainability.
When a component needs to track internal values that change in response to user input or internal logic, local state is sufficient.
useState in React, or @state in Svelte, or ref in Vue.function SearchInput() {
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const handleInputChange = (event) => {
setQuery(event.target.value);
setIsSearching(true);
setTimeout(() => setIsSearching(false), 300);
};
return (
<div>
<input
type="text"
value={query}
onChange={handleInputChange}
placeholder="Search..."
/>
{isSearching && <span className="spinner">Loading...</span>}
</div>
);
}
Common pitfalls:
useState re-creates the state object on every render, so setFoo callbacks may not capture the latest state unless explicitly memoized.useReducer: When state logic grows beyond useState, but the reducer is not memoized with useMemo, redundant re-renders occur.> Failure mode: A form with five fields, each using useState, but setField calls are not batched. When a user types into a field, the component re-renders five times, once for each setX call.
Solution: Combine multiple useState updates into a single useReducer or use useCallback to memoize handler functions.
const [form, setForm] = useReducer((state, action) => {
switch (action.type) {
case 'update':
return { ...state, [action.field]: action.value };
case 'reset':
return { name: '', email: '', notes: '' };
default:
return state;
}
}, { name: '', email: '', notes: '' });
const updateField = useCallback((field, value) => {
setForm({ type: 'update', field, value });
}, []);
When multiple components need to access or modify the same data, global state is necessary.
Use Context for state that flows top-down through the component tree.
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(setUser)
.finally(() => setLoading(false));
}, []);
return (
<UserContext.Provider value={{ user, loading, setUser }}>
{children}
</UserContext.Provider>
);
}
function UserProfile() {
const { user, loading, setUser } = useContext(UserContext);
if (loading) return <div>Loading user...</div>;
return (
<div>
<h2>{user.name}</h2>
<button onClick={() => setUser(null)}>Logout</button>
</div>
);
}
Key failure modes:
setUser updates the context but does not trigger a re-render in components using useContext, especially if setUser is not memoized.UserContext.Provider re-renders on every parent update, causing unnecessary re-renders in all consumers.Solution: Memoize the provider value using useMemo.
const value = useMemo(() => ({ user, loading, setUser }), [user, loading, setUser]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
For more complex state with actions, side effects, and derived data, use a centralized store.
dispatch({ type: 'ADD_TODO', payload: { text } }).const initialState = { todos: [], filter: 'all' };
function todoReducer(state, action) {
switch (action.type) {
case 'ADD_TODO':
return {
...state,
todos: [...state.todos, { id: Date.now(), text: action.payload.text, completed: false }]
};
case 'TOGGLE_TODO':
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed }
: todo
)
};
case 'SET_FILTER':
return { ...state, filter: action.payload.filter };
default:
return state;
}
}
Common configuration keys:
store.getState() — returns the current state tree.store.dispatch(action) — enqueues an action and updates state.store.subscribe(callback) — registers a callback to run on every state change.Failure modes:
state.todos in a reducer leads to subtle bugs and missed re-renders.> Error text: TypeError: Cannot read property 'map' of undefined — likely because the reducer returned undefined for an unknown action type.
Solution: Use immer to write mutable reducers that produce immutable results.
import produce from 'immer';
function todoReducer(state, action) {
return produce(state, draft => {
switch (action.type) {
case 'ADD_TODO':
draft.todos.push({
id: Date.now(),
text: action.payload.text,
completed: false
});
break;
case 'TOGGLE_TODO':
const todo = draft.todos.find(t => t.id === action.payload.id);
todo.completed = !todo.completed;
break;
default:
return state;
}
});
}
When state depends on server data, it must be synchronized across client and server.
Components fetch data and update state when responses arrive.
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/posts')
.then(res => {
if (!res.ok) throw new Error('Failed to load posts');
return res.json();
})
.then(setPosts)
.catch(setError)
.finally(() => setLoading(false));
return () => {
// cleanup: cancel fetch if component unmounts
// (via AbortController or fetch with signal)
};
}, []);
if (loading) return <div>Loading posts...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Failure modes:
Solution: Implement client-side caching with fetch and cache-control headers.
async function fetchWithCache(url, options = {}) {
const cacheKey = `cache-${url}`;
const cached = localStorage.getItem(cacheKey);
const now = Date.now();
if (cached) {
const { data, timestamp } = JSON.parse(cached);
const age = now - timestamp;
if (age < 30_000) { // 30 seconds
return data;
}
}
const response = await fetch(url, options);
const data = await response.json();
localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: now }));
return data;
}
For applications requiring live updates — chat, dashboards, collaborative editors — use WebSocket or polling.
function ChatRoom() {
const [messages, setMessages] = useState([]);
const [ws, setWs] = useState(null);
useEffect(() => {
const socket = new WebSocket('wss://chat.example.com/ws');
socket.onmessage = event => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
socket.onopen = () => {
console.log('Connected to chat server');
socket.send(JSON.stringify({ type: 'join', userId: 123 }));
};
socket.onerror = error => {
console.error('WebSocket error:', error);
};
socket.onclose = () => {
console.log('WebSocket closed');
// Reconnect after 3 seconds
setTimeout(() => {
setWs(new WebSocket('wss://chat.example.com/ws'));
}, 3000);
};
setWs(socket);
return () => socket.close();
}, []);
return (
<div>
<ul>
{messages.map(msg => (
<li key={msg.id}>{msg.text}</li>
))}
</ul>
</div>
);
}
Failure modes:
Solution: Use a messageId field and a lastReceivedId tracking mechanism.
// On client: track the latest message ID received
const [lastReceivedId, setLastReceivedId] = useState(0);
socket.onmessage = event => {
const message = JSON.parse(event.data);
if (message.id > lastReceivedId) {
setMessages(prev => [...prev, message]);
setLastReceivedId(message.id);
}
};
When state depends on other state, compute derived values efficiently.
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const filteredTodos = todos.filter(todo => {
switch (filter) {
case 'active':
return !todo.completed;
case 'completed':
return todo.completed;
default:
return true;
}
});
const completedCount = todos.filter(t => t.completed).length;
const totalCount = todos.length;
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
return (
<div>
<div>
<span>Progress: {progress.toFixed(1)}%</span>
<span>({completedCount}/{totalCount})</span>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
</li>
))}
</ul>
</div>
);
}
Failure mode: filteredTodos is recalculated on every render, even when todos or filter haven’t changed.
Solution: Use useMemo to memoize derived state.
const filteredTodos = useMemo(() => {
return todos.filter(todo => {
switch (filter) {
case 'active':
return !todo.completed;
case 'completed':
return todo.completed;
default:
return true;
}
});
}, [todos, filter]);
entities: { [id]: item }, ids: [id1, id2], loading: { [key]: boolean }, errors: { [key]: string[] }.action.type.error and loading flags in state, and handle errors in both fetch and dispatch.initialState as a plain object, not a function, to avoid unnecessary re-creations.> Common error: Cannot read property 'map' of undefined — todos is undefined because useEffect runs before setTodos has been called.
> Best practice: Use useReducer with useMemo for state that is updated frequently and has complex logic.
> Advanced technique: Implement a useStore hook that abstracts access to a global store.
function useStore(selector) {
const [state, dispatch] = useContext(StoreContext);
const selectedState = useMemo(() => selector(state), [state, selector]);
return [selectedState, dispatch];
}
This pattern enables predictable, testable, and scalable state management across all levels of a frontend application.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy