Frontend State Management Patterns
A practical guide to managing application state in modern frontend applications — comparing local state, context, Redux, Zustand, signals, and server-state solutions.
State management is the most debated topic in frontend engineering. Not because the problem is inherently complex, but because teams consistently over-engineer their approach. The right solution depends on the type of state, the size of the application, and the team’s preferences.
State Categories
Understanding what kind of state you’re managing is the first step to choosing the right tool:
| Category | Examples | Characteristics |
|---|---|---|
| UI State | Modal open/closed, active tab, form input | Local, ephemeral, component-scoped |
| Client State | Theme preference, sidebar collapsed, feature flags | Shared across components, persists within session |
| Server State | User profile, product list, dashboard data | Fetched from API, cached, stale, needs sync |
| URL State | Current route, query parameters, filters | Shareable, bookmarkable, navigation-linked |
| Form State | Field values, validation errors, dirty tracking | Complex lifecycle, validation rules |
The State Management Spectrum
Simplest Most Complex
├──────────┬───────────┬───────────┬──────────┬──────────┤
useState useReducer Context Zustand Redux/
Jotai MobX/XState
Rule of Thumb
Use the simplest tool that solves your problem. If useState works, don’t reach for Redux. If Context works, don’t add Zustand. Complexity should be justified by requirements.
Pattern 1: Local Component State
For UI state that belongs to a single component:
function SearchBox() {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
return (/* ... */);
}
When to use: Toggle states, form inputs, component-specific UI. This covers 70% of state management needs.
Pattern 2: Lifted State / Prop Drilling
When siblings need to share state, lift it to the nearest common ancestor:
function FilterableProductList() {
const [filters, setFilters] = useState({});
return (
<>
<FilterBar filters={filters} onChange={setFilters} />
<ProductList filters={filters} />
</>
);
}
When to use: 2-3 levels of depth, small number of shared values. Stop when prop drilling becomes painful (> 3 levels).
Pattern 3: React Context
For state that many components need but doesn’t change frequently:
const ThemeContext = createContext('light');
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
When to use: Theme, locale, auth status, feature flags — data that changes infrequently but is needed everywhere.
When NOT to use: Frequently changing data (causes re-renders for all consumers) or large state objects.
Pattern 4: External State Stores
For complex client state with frequent updates:
Zustand (lightweight, hooks-first):
const useStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
}));
function ItemList() {
const items = useStore((state) => state.items);
return items.map(item => <Item key={item.id} {...item} />);
}
When to use: Shopping carts, complex multi-step forms, collaborative editing state.
Pattern 5: Server State
API data has unique characteristics: it’s asynchronous, cacheable, shared, and becomes stale. Treat it differently from client state.
React Query / TanStack Query:
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorState error={error} />;
return <Profile user={data} />;
}
What you get for free: Caching, deduplication, background refetching, optimistic updates, pagination, infinite scroll.
When to use: Always, for any data fetched from an API. This is the single biggest improvement most frontend teams can make.
Choosing the Right Pattern
Is it server data?
→ Yes → React Query / SWR / Apollo
→ No → continue
Is it URL state?
→ Yes → Router (useSearchParams, useRouter)
→ No → continue
Does only one component need it?
→ Yes → useState / useReducer
→ No → continue
Do 2-3 nearby components need it?
→ Yes → Lift state to parent
→ No → continue
Does it change infrequently?
→ Yes → React Context
→ No → External store (Zustand, Jotai)
Anti-Patterns
Global State by Default
Not everything belongs in global state. Most state is local. Start local and lift only when you have a real need.
Storing Server Data in Client State
Don’t useEffect → fetch → setState. This ignores caching, deduplication, loading states, error handling, and staleness. Use a server-state library.
Context for Frequently Changing Data
React Context triggers re-renders in every consumer when the value changes. If your context value changes on every keystroke, you’ll have performance problems.
State Duplication
Don’t store the same data in multiple places. Derive computed values instead of storing them:
// Bad — storing derived state
const [items, setItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
// Must manually sync totalPrice whenever items changes
// Good — derive it
const [items, setItems] = useState([]);
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
Redux for Simple Apps
Redux adds boilerplate for actions, reducers, selectors, and middleware. If your app has a handful of screens & simple data flows, Redux is unnecessary overhead.
Performance Considerations
Selective Subscriptions
External stores like Zustand allow subscribing to specific slices of state, preventing unnecessary re-renders:
// Only re-renders when `count` changes, not when other state changes
const count = useStore((state) => state.count);
Memoization
Use useMemo and useCallback when passing derived values or callbacks through Context or as props to memoized components.
State Colocation
Keep state as close to where it’s used as possible. Moving state higher in the tree causes re-renders to cascade further.
The best state management solution is the one your team understands, maintains, and doesn’t fight against. Simplicity wins.