State Management Patterns
State is the data that drives your UI. Where it lives, how it flows, and how it's updated determines whether your app is predictable or chaotic. Understanding the spectrum — from useState to Redux to server state — is essential for frontend architecture decisions.
Table of Contents
Overview
State management is how you store, update, and share data across your application. In React, state drives the UI — when state changes, components re-render. The challenge is deciding where state lives and how it flows between components.
For a small app, useState is enough. As the app grows, you need patterns for sharing state across components (lifting state, Context), managing complex state transitions (useReducer, Redux), and handling server data (React Query, SWR). Choosing the wrong pattern leads to prop drilling, unnecessary re-renders, and unmaintainable code.
In interviews, "How would you manage state in this application?" tests whether you can match the right tool to the right problem — not whether you know Redux syntax. The best answer explains the spectrum of options and justifies the choice.
Why this matters
State management is the single biggest architectural decision in a frontend app. It affects performance (re-renders), developer experience (debugging), and scalability (adding features). Get it right and the app is predictable. Get it wrong and every feature becomes a fight against the framework.
The Problem: Managing State at Scale
Simple state management works until it doesn't. As applications grow, three problems emerge that basic patterns can't solve cleanly.
// User data needed 4 levels deep function App() { const [user, setUser] = useState({ name: "Alice", role: "admin" }); return <Layout user={user} />; // pass down } function Layout({ user }) { return <Sidebar user={user} />; // pass down again } function Sidebar({ user }) { return <UserMenu user={user} />; // pass down AGAIN } function UserMenu({ user }) { return <span>{user.name}</span>; // finally used here } // Layout and Sidebar don't USE user — they just pass it through. // This is "prop drilling": threading props through components // that don't need them, just to reach a deeply nested child. // // Problems: // ❌ Every intermediate component must know about "user" prop // ❌ Adding a new field to user means updating 4 components // ❌ Renaming the prop means editing every level // ❌ Components become coupled to data they don't use
Component Tree: App / \ Header Content / \ CartIcon ProductList │ │ "Show cart count" "Add to cart" CartIcon needs to display the cart count. ProductList needs to add items to the cart. They're siblings — neither is a parent of the other. Where does cart state live? • In CartIcon? → ProductList can't update it • In ProductList? → CartIcon can't read it • In App? → Prop drilling through Header and Content • In Context? → Re-renders every consumer on any change • In Zustand/Redux? → External store, both read/write directly
Prop Drilling
Passing data through 3-5 levels of components that don't use it. Makes refactoring painful and couples components to data they don't care about.
State Synchronization
Two components showing the same data (cart count in header + cart page). If they have separate state, they can get out of sync. Single source of truth is essential.
Re-render Cascades
Updating state high in the tree re-renders everything below it. A theme toggle in App re-renders the entire application — even components that don't use the theme.
The core question
Every state management decision answers: "Where does this state live, and how do the components that need it access it?" The answer depends on how many components need the state, how often it changes, and whether it comes from the server or the client.
Types of State
Not all state is the same. Categorizing state by scope and origin is the first step to choosing the right management pattern.
By Scope: ┌──────────────────────────────────────────────────┐ │ LOCAL STATE │ │ • Belongs to one component │ │ • Examples: form input, toggle, dropdown open │ │ • Tool: useState │ ├──────────────────────────────────────────────────┤ │ SHARED STATE │ │ • Needed by 2-5 nearby components │ │ • Examples: selected tab, filter criteria │ │ • Tool: lift state up, or Context │ ├──────────────────────────────────────────────────┤ │ GLOBAL STATE │ │ • Needed across the entire app │ │ • Examples: auth user, theme, locale │ │ • Tool: Context, Zustand, Redux │ └──────────────────────────────────────────────────┘ By Origin: ┌──────────────────────────────────────────────────┐ │ CLIENT STATE │ │ • Created and owned by the frontend │ │ • Examples: UI state, form data, selections │ │ • Tool: useState, useReducer, Zustand, Redux │ ├──────────────────────────────────────────────────┤ │ SERVER STATE │ │ • Fetched from an API, cached on the client │ │ • Examples: user profile, product list, orders │ │ • Tool: React Query, SWR, RTK Query │ │ • Has unique concerns: caching, revalidation, │ │ loading states, optimistic updates │ └──────────────────────────────────────────────────┘
| State Type | Example | Scope | Best Tool |
|---|---|---|---|
| Form input value | Search query text | Local | useState |
| Dropdown open/closed | isMenuOpen | Local | useState |
| Selected filter | Active category tab | Shared (2-3 components) | Lift state up |
| Shopping cart | Cart items array | Global (header + cart page) | Zustand / Redux |
| Auth user | Current logged-in user | Global (entire app) | Context or Zustand |
| Theme / locale | Dark mode, language | Global (rarely changes) | Context |
| Product list | Fetched from /api/products | Server state | React Query / SWR |
| User profile | Fetched from /api/me | Server state | React Query / SWR |
The most important distinction
Server state and client state are fundamentally different. Server state is a cache of remote data — it has loading/error states, can become stale, and needs revalidation. Client state is owned by the frontend — it's synchronous and always up-to-date. Mixing them in the same store (putting API data in Redux) is the #1 state management mistake.
Local State (useState)
The simplest and most common pattern. State lives inside a single component and is managed with useState or useReducer. No other component can access it directly.
// Simple: boolean toggle function Dropdown() { const [isOpen, setIsOpen] = useState(false); return ( <div> <button onClick={() => setIsOpen(!isOpen)}>Menu</button> {isOpen && <ul className="dropdown-menu">...</ul>} </div> ); } // Medium: form with multiple fields function ContactForm() { const [form, setForm] = useState({ name: "", email: "", message: "" }); const [errors, setErrors] = useState({}); const handleChange = (field, value) => { setForm(prev => ({ ...prev, [field]: value })); }; const handleSubmit = () => { const newErrors = validate(form); if (Object.keys(newErrors).length === 0) { submitForm(form); } else { setErrors(newErrors); } }; return (/* form JSX */); } // Complex: useReducer for state machines function TodoList() { const [state, dispatch] = useReducer(todoReducer, { todos: [], filter: "all" }); return ( <div> <button onClick={() => dispatch({ type: "ADD", text: "New todo" })}>Add</button> <button onClick={() => dispatch({ type: "SET_FILTER", filter: "done" })}>Done</button> {/* render filtered todos */} </div> ); } // When to use local state: // ✅ Only one component needs this data // ✅ UI state: open/closed, active tab, hover, form inputs // ✅ State doesn't need to survive component unmount // ✅ No other component needs to read or write this state
Default to local state
Start with useState for everything. Only move state "up" or "out" when another component actually needs it. This is called state colocation — keeping state as close to where it's used as possible. It's the simplest pattern and causes the fewest re-renders.
Lifting State Up
When two sibling components need the same state, move it to their nearest common parent. The parent owns the state and passes it down as props.
// ❌ BEFORE: Duplicated state — siblings can't share function SearchBar() { const [query, setQuery] = useState(""); // SearchBar owns query return <input value={query} onChange={e => setQuery(e.target.value)} />; } function ResultsList() { const [query, setQuery] = useState(""); // ResultsList has its OWN query // These two "query" states are independent — they don't sync! return <ul>{/* filter by query */}</ul>; } // ✅ AFTER: Lifted state — parent owns, children share function SearchPage() { const [query, setQuery] = useState(""); // Parent owns the state return ( <div> <SearchBar query={query} onQueryChange={setQuery} /> <ResultsList query={query} /> </div> ); } function SearchBar({ query, onQueryChange }) { return ( <input value={query} onChange={e => onQueryChange(e.target.value)} /> ); } function ResultsList({ query }) { const filtered = allResults.filter(r => r.title.toLowerCase().includes(query.toLowerCase()) ); return <ul>{filtered.map(r => <li key={r.id}>{r.title}</li>)}</ul>; } // Now SearchBar and ResultsList share the same query. // SearchBar writes it (via callback), ResultsList reads it (via prop). // Single source of truth — always in sync.
| Pros | Cons |
|---|---|
| Simple — no extra libraries or patterns | Can lead to prop drilling if state is needed deep in the tree |
| Single source of truth — siblings always in sync | Parent re-renders on every state change (and all children) |
| Easy to understand and debug | Doesn't scale beyond 2-3 levels of prop passing |
| Works for 2-5 components sharing state | Not suitable for app-wide state (auth, theme) |
When lifting state breaks down
Lifting state works for nearby components (parent + 2-3 children). When state needs to be shared across distant parts of the tree (header and footer, sidebar and main content), lifting it to the root causes prop drilling and unnecessary re-renders. That's when you need Context or an external store.
Context API
React Context lets you pass data through the component tree without prop drilling. Any component inside the Provider can access the value directly, regardless of depth.
// 1. Create the context const AuthContext = createContext(null); // 2. Create a provider component function AuthProvider({ children }) { const [user, setUser] = useState(null); const login = async (email, password) => { const res = await fetch("/api/login", { method: "POST", body: JSON.stringify({ email, password }), }); const data = await res.json(); setUser(data.user); }; const logout = () => setUser(null); return ( <AuthContext.Provider value={{ user, login, logout }}> {children} </AuthContext.Provider> ); } // 3. Create a custom hook for clean access function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error("useAuth must be used within AuthProvider"); return context; } // 4. Wrap the app function App() { return ( <AuthProvider> <Header /> {/* can access user */} <MainContent /> {/* can access user */} <Footer /> {/* can access user */} </AuthProvider> ); } // 5. Use anywhere — no prop drilling function UserMenu() { const { user, logout } = useAuth(); if (!user) return <LoginButton />; return ( <div> <span>{user.name}</span> <button onClick={logout}>Logout</button> </div> ); } // UserMenu accesses auth directly — no props passed through // Header, Layout, Sidebar, etc.
| Context is Good For | Context is Bad For |
|---|---|
| Theme (dark/light) — changes rarely | Shopping cart — changes frequently, many consumers |
| Auth user — read by many, updated rarely | Form state — local to one component |
| Locale/language — app-wide, stable | Real-time data — high update frequency |
| Feature flags — set once, read everywhere | Complex state with many actions (use Zustand/Redux) |
The re-render problem
When a Context value changes, every component that calls useContext re-renders — even if it only uses one field from the value. For frequently changing state with many consumers, this causes performance issues. That's why Context is best for low-frequency, app-wide state (theme, auth, locale).
External State Libraries
When Context isn't enough — frequent updates, complex state logic, or fine-grained subscriptions — external libraries provide more powerful patterns.
import { create } from "zustand"; // Create a store — no Provider needed const useCartStore = create((set) => ({ items: [], addItem: (product) => set((state) => ({ items: [...state.items, { ...product, quantity: 1 }], })), removeItem: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id), })), clearCart: () => set({ items: [] }), // Derived state get totalItems() { return this.items.reduce((sum, item) => sum + item.quantity, 0); }, })); // Use in any component — no Provider wrapper function CartIcon() { const totalItems = useCartStore((state) => state.items.length); return <span className="badge">{totalItems}</span>; } function ProductCard({ product }) { const addItem = useCartStore((state) => state.addItem); return ( <div> <h3>{product.name}</h3> <button onClick={() => addItem(product)}>Add to Cart</button> </div> ); } // Key advantages over Context: // ✅ No Provider wrapper needed // ✅ Selector-based subscriptions — CartIcon only re-renders // when items.length changes, not on every state update // ✅ Simple API — create() + use the hook // ✅ Works outside React (in utility functions, middleware)
import { createSlice, configureStore } from "@reduxjs/toolkit"; // Define a slice (feature-scoped state + reducers) const cartSlice = createSlice({ name: "cart", initialState: { items: [] }, reducers: { addItem: (state, action) => { state.items.push({ ...action.payload, quantity: 1 }); }, removeItem: (state, action) => { state.items = state.items.filter(item => item.id !== action.payload); }, clearCart: (state) => { state.items = []; }, }, }); // Create the store const store = configureStore({ reducer: { cart: cartSlice.reducer }, }); // Use in components with useSelector + useDispatch function CartIcon() { const itemCount = useSelector(state => state.cart.items.length); return <span>{itemCount}</span>; } function ProductCard({ product }) { const dispatch = useDispatch(); return ( <button onClick={() => dispatch(cartSlice.actions.addItem(product))}> Add to Cart </button> ); } // Redux is best for: // ✅ Large apps with complex state logic // ✅ Teams that need strict patterns and predictability // ✅ DevTools with time-travel debugging // ✅ Middleware for side effects (thunks, sagas)
| Library | Boilerplate | Learning Curve | Best For |
|---|---|---|---|
| Zustand | Minimal | Low | Small-medium apps, simple global state |
| Redux Toolkit | Medium | Medium | Large apps, complex state, team conventions |
| Jotai | Minimal | Low | Atomic state, fine-grained reactivity |
| Recoil | Medium | Medium | Derived state, async selectors (Meta projects) |
Zustand is the modern default
For most new React projects, Zustand has become the go-to choice. It has minimal boilerplate, no Provider wrapper, built-in selector subscriptions (no unnecessary re-renders), and works outside React. Redux Toolkit is still excellent for large teams that need strict conventions and powerful DevTools.
Server State vs Client State
This is the most important distinction in modern state management. Server state and client state have fundamentally different characteristics and need different tools.
CLIENT STATE SERVER STATE ───────────── ──────────── • Created by the frontend • Fetched from an API • Synchronous — always available • Asynchronous — loading/error states • Always up-to-date (you own it) • Can become stale (server may have changed) • No caching needed • Caching is critical (avoid refetching) • Examples: • Examples: - isMenuOpen - User profile from /api/me - selectedTab - Product list from /api/products - formInputValue - Order history from /api/orders - theme (dark/light) - Notifications from /api/notifications Tools: Tools: useState, useReducer, React Query (TanStack Query), Zustand, Redux SWR, RTK Query The mistake: putting server data in Redux/Zustand → You must manually handle loading, error, caching, revalidation, pagination, optimistic updates... → React Query handles ALL of this automatically.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; // Fetching server state — React Query handles everything function UserProfile() { const { data: user, isLoading, error } = useQuery({ queryKey: ["user", "me"], queryFn: () => fetch("/api/me").then(res => res.json()), staleTime: 5 * 60 * 1000, // cache for 5 minutes }); if (isLoading) return <Spinner />; if (error) return <Error message={error.message} />; return <ProfileCard user={user} />; } // Mutating server state — with optimistic update function TodoItem({ todo }) { const queryClient = useQueryClient(); const toggleMutation = useMutation({ mutationFn: (id) => fetch(`/api/todos/${id}/toggle`, { method: "PATCH" }), // Optimistic update: update UI immediately onMutate: async (id) => { await queryClient.cancelQueries({ queryKey: ["todos"] }); const previous = queryClient.getQueryData(["todos"]); queryClient.setQueryData(["todos"], (old) => old.map(t => t.id === id ? { ...t, done: !t.done } : t) ); return { previous }; }, // Rollback on error onError: (err, id, context) => { queryClient.setQueryData(["todos"], context.previous); }, }); return ( <li onClick={() => toggleMutation.mutate(todo.id)}> {todo.text} </li> ); } // What React Query gives you for free: // ✅ Loading & error states // ✅ Automatic caching & deduplication // ✅ Background refetching (stale-while-revalidate) // ✅ Optimistic updates with rollback // ✅ Pagination & infinite scroll support // ✅ Automatic garbage collection of unused data
| Client State (Zustand/Redux) | Server State (React Query) | |
|---|---|---|
| Data source | Created in the browser | Fetched from API |
| Loading state | Not needed (synchronous) | Built-in (isLoading, isFetching) |
| Caching | Not needed | Automatic with staleTime/cacheTime |
| Staleness | Always fresh (you own it) | Can become stale (server may change) |
| Revalidation | Not applicable | Automatic (on focus, on reconnect, on interval) |
| Deduplication | Manual | Automatic (same queryKey = one request) |
The modern stack
The recommended approach for most apps: React Query for server state + Zustand (or useState) for client state. This eliminates 80% of what Redux was traditionally used for. Redux was often used to cache API data — React Query does that better with less code.
Pattern Comparison
Here's the full landscape. Each pattern has a sweet spot — the key is matching the pattern to the problem.
| Pattern | Complexity | Scope | Re-render Control | Best For |
|---|---|---|---|---|
| useState | Trivial | Single component | Perfect (local only) | UI state, form inputs, toggles |
| Lift state up | Low | Parent + 2-3 children | Good (parent re-renders children) | Sibling communication, shared filters |
| useReducer | Low | Single component | Perfect (local only) | Complex state transitions, state machines |
| Context API | Medium | Subtree or app-wide | Poor (all consumers re-render) | Theme, auth, locale (low-frequency) |
| Zustand | Medium | App-wide | Great (selector subscriptions) | Global client state, cart, UI preferences |
| Redux Toolkit | Medium-High | App-wide | Great (selector subscriptions) | Large apps, complex logic, team conventions |
| React Query | Medium | Server data cache | Great (query-level subscriptions) | API data, caching, revalidation |
They're not mutually exclusive
A real app uses multiple patterns simultaneously. useState for form inputs, Context for theme/auth, Zustand for cart state, React Query for API data. The skill is knowing which pattern to use for which type of state.
Decision Framework
Use this decision tree when choosing a state management approach. Start from the top and follow the path that matches your situation.
Is this data from an API (server state)? │ ├─ YES → Use React Query / SWR / RTK Query │ Handles caching, loading, revalidation automatically. │ Don't put API data in Redux or Zustand. │ └─ NO → It's client state. How many components need it? │ ├─ ONE component → useState (or useReducer if complex) │ Keep it local. Don't over-engineer. │ ├─ 2-3 NEARBY components → Lift state up │ Move state to nearest common parent. │ Pass via props. Simple and effective. │ ├─ MANY components, CHANGES RARELY → Context API │ Theme, auth user, locale, feature flags. │ Wrap in Provider, access with useContext. │ └─ MANY components, CHANGES OFTEN → Zustand / Redux Shopping cart, notifications, complex UI state. Selector-based subscriptions prevent re-render storms. Quick rules: • Default to useState — move up only when needed • Server data → React Query (never Redux for API caching) • Global + low frequency → Context • Global + high frequency → Zustand or Redux • Complex transitions → useReducer (local) or Redux (global)
Ask: Is it server state or client state?
If the data comes from an API, use React Query. Full stop. It handles caching, loading states, revalidation, and deduplication. Don't reinvent this with Redux or Zustand.
Ask: How many components need this state?
One component → useState. Two nearby siblings → lift state up. Many distant components → Context or external store. The answer determines the pattern.
Ask: How often does this state change?
Rarely (theme, auth) → Context is fine. Frequently (cart, form, real-time) → Zustand or Redux with selectors. Context re-renders all consumers on every change.
Ask: How complex are the state transitions?
Simple (toggle, set value) → useState. Complex (multi-step, conditional) → useReducer locally or Redux for global. Reducers make complex transitions predictable.
The interview answer
"I start with useState for everything. When two components need the same state, I lift it up. For app-wide state that changes rarely (theme, auth), I use Context. For app-wide state that changes often (cart, notifications), I use Zustand. For server data, I use React Query. I never put API data in a client state store."
Real-World Example
Let's architect the state management for a real e-commerce dashboard, showing how different patterns coexist in one application.
┌─────────────────────────────────────────────────┐ │ App │ │ │ │ Context: AuthProvider (user, login, logout) │ │ Context: ThemeProvider (theme, toggleTheme) │ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ Header │ │ │ │ ├── UserMenu (useAuth → Context) │ │ │ │ ├── CartIcon (useCartStore → Zustand) │ │ │ │ └── ThemeToggle (useTheme → Context) │ │ │ └──────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────┐ │ │ │ Dashboard Page │ │ │ │ ├── StatsCards (useQuery → React Query) │ │ │ │ ├── RevenueChart (useQuery → React Query)│ │ │ │ ├── RecentOrders (useQuery → React Query)│ │ │ │ └── FilterBar (useState → local) │ │ │ └───────────────────────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ Product Page │ │ │ │ ├── ProductList (useQuery → React Query)│ │ │ │ ├── SearchBar (useState → local) │ │ │ │ └── AddToCart (useCartStore → Zustand) │ │ │ └──────────────────────────────────────────┘ │ └─────────────────────────────────────────────────┘ State breakdown: useState: SearchBar query, FilterBar selections, form inputs Context: Auth user (read everywhere, changes on login/logout) Theme (read everywhere, changes on toggle) Zustand: Cart items (read by CartIcon + CartPage, written by ProductPage) React Query: Stats, revenue, orders, products (all from API)
// ── Context: Auth (changes rarely, needed everywhere) ── const AuthContext = createContext(null); function AuthProvider({ children }) { const [user, setUser] = useState(null); // login/logout logic... return ( <AuthContext.Provider value={{ user, login, logout }}> {children} </AuthContext.Provider> ); } // ── Zustand: Cart (changes often, needed in header + product + cart pages) ── const useCartStore = create((set) => ({ items: [], addItem: (product) => set((s) => ({ items: [...s.items, product] })), removeItem: (id) => set((s) => ({ items: s.items.filter(i => i.id !== id) })), total: () => 0, // computed in selector })); // ── React Query: Products (server state, cached) ── function useProducts(category) { return useQuery({ queryKey: ["products", category], queryFn: () => fetch(`/api/products?cat=${category}`).then(r => r.json()), staleTime: 2 * 60 * 1000, }); } // ── Component: ProductPage (composes all patterns) ── function ProductPage() { const [search, setSearch] = useState(""); // local state const { user } = useAuth(); // context const addItem = useCartStore((s) => s.addItem); // zustand const { data: products, isLoading } = useProducts(); // react query const filtered = products?.filter(p => p.name.toLowerCase().includes(search.toLowerCase()) ); return ( <div> <SearchBar value={search} onChange={setSearch} /> {isLoading ? <Spinner /> : ( <ProductGrid products={filtered} onAddToCart={user ? addItem : () => alert("Please login")} /> )} </div> ); }
Four patterns, one page
The ProductPage uses all four patterns simultaneously — and each is the right tool for its job. useState for the search input (local, ephemeral). Context for auth (global, rarely changes). Zustand for cart (global, changes often). React Query for products (server data, cached). This is what good state architecture looks like.
Performance Insights
State management directly impacts rendering performance. The wrong pattern causes unnecessary re-renders that slow down the app.
State Colocation
Keep state as close to where it's used as possible. A search input's state should live in the SearchBar component, not in a global store. Local state never causes re-renders in other components.
Selector Subscriptions
Zustand and Redux let components subscribe to specific slices of state. CartIcon subscribes to items.length, not the entire cart. When item details change, CartIcon doesn't re-render.
Context Splitting
Split large contexts into smaller ones. Instead of one AppContext with theme + auth + locale, use ThemeContext + AuthContext + LocaleContext. A theme change won't re-render auth consumers.
Memoization
Use useMemo for expensive derived state and React.memo for components that receive stable props. Prevents re-computation and re-rendering when parent state changes but child props don't.
Scenario: Cart has 10 items. User adds 1 more item. ── Context (no optimization) ────────────────────── CartContext value changes → ALL consumers re-render: ✗ CartIcon re-renders (needs item count) ✗ CartPage re-renders (needs item list) ✗ CheckoutButton re-renders (needs total price) ✗ Header re-renders (just reads cart for badge) ✗ ProductCard re-renders (reads addItem function) = 5 re-renders, even though most only need one field ── Zustand (with selectors) ─────────────────────── Cart store updates → only subscribed slices trigger: ✓ CartIcon re-renders (subscribed to items.length) ✓ CartPage re-renders (subscribed to items array) ✗ CheckoutButton skipped (subscribed to total — unchanged) ✗ Header skipped (subscribed to items.length — same as CartIcon) ✗ ProductCard skipped (subscribed to addItem — stable reference) = 2 re-renders, only components whose slice actually changed
The performance hierarchy
From best to worst re-render performance: (1) Local useState — only the component re-renders. (2) Zustand/Redux with selectors — only components whose slice changed re-render. (3) Lifted state — parent + all children re-render. (4) Context — all consumers re-render on any change. Choose accordingly.
Common Mistakes
These mistakes are the most common reasons state management becomes painful in real projects.
Using Redux for everything
Putting form inputs, toggle states, and API data all in Redux. Every state change dispatches an action, goes through reducers, and triggers selectors. Massive overhead for simple state.
✅Use Redux only for complex global client state. Use useState for local UI state. Use React Query for server data. Most apps need far less Redux than they think.
Putting server data in client stores
Fetching API data in useEffect, storing it in Redux/Zustand, and manually handling loading, error, caching, and revalidation. Hundreds of lines of code that React Query handles automatically.
✅Use React Query (TanStack Query) or SWR for all server state. They handle caching, loading states, revalidation, deduplication, and optimistic updates out of the box.
Overusing Context for frequent updates
Putting rapidly changing state (form inputs, real-time data, animations) in Context. Every keystroke re-renders every consumer of that Context, causing visible lag.
✅Context is for low-frequency state (theme, auth, locale). For frequently changing state shared across components, use Zustand or Redux with selector subscriptions.
Lifting all state to the root
Moving every piece of state to the App component 'just in case' another component needs it. The root re-renders on every state change, cascading through the entire tree.
✅Keep state as close to where it's used as possible (colocation). Only lift state when a sibling actually needs it. Use Context or Zustand for truly global state.
One giant global store
A single Redux store with 50 slices, or one Zustand store with every piece of state in the app. Hard to reason about, hard to debug, and every component is coupled to the store shape.
✅Split stores by domain. Zustand: create separate stores (useCartStore, useAuthStore, useUIStore). Redux: use feature-scoped slices. Keep stores focused and independent.
Not separating derived state
Storing computed values (filtered list, total price, formatted date) in state and manually keeping them in sync with source data. They inevitably get out of sync.
✅Derive state on render or with useMemo. filteredUsers = users.filter(...) — computed from source state, always in sync. Never store what you can compute.
Interview Questions
These questions test architectural thinking — not library syntax. Strong answers explain trade-offs and match patterns to problems.
Q:What is state management and why does it matter?
A: State management is how you store, update, and share data that drives the UI. It matters because state determines what the user sees — when state is disorganized, the UI becomes unpredictable (stale data, out-of-sync components, unnecessary re-renders). Good state management means each piece of state has a clear owner, a single source of truth, and an efficient update path.
Q:When would you use Context API vs Redux/Zustand?
A: Context is best for low-frequency, app-wide state: theme, auth user, locale, feature flags. It re-renders all consumers on any change, so it's unsuitable for frequently updating state. Redux/Zustand are best for global state that changes often (cart, notifications, complex UI state) because they support selector-based subscriptions — components only re-render when their specific slice changes.
Q:What is the difference between server state and client state?
A: Client state is created and owned by the frontend (form inputs, UI toggles, selections). It's synchronous and always up-to-date. Server state is fetched from an API and cached on the client (user profile, product list). It's asynchronous (loading/error states), can become stale, and needs revalidation. They need different tools: useState/Zustand for client state, React Query/SWR for server state.
Q:Why shouldn't you put API data in Redux?
A: API data is server state with unique concerns: loading states, error handling, caching, staleness, revalidation, deduplication, pagination, and optimistic updates. Redux doesn't handle any of these — you'd write hundreds of lines of boilerplate. React Query handles all of them automatically with a few lines. Redux is for client state (UI state, complex transitions). React Query is for server state (API data).
Q:How do you avoid prop drilling?
A: Four strategies: (1) State colocation — keep state close to where it's used, reducing the need to pass it far. (2) Component composition — pass components as children instead of data as props. (3) Context API — for state needed by many distant components (theme, auth). (4) External stores (Zustand/Redux) — any component can read/write directly without props. Choose based on how many components need the state and how often it changes.
Q:How would you manage state in a large e-commerce app?
A: Layer the patterns: useState for local UI state (form inputs, toggles, dropdowns). Context for auth and theme (global, rarely changes). Zustand for cart state (global, changes often, needed by header + product pages + cart page). React Query for all API data (products, orders, user profile — with caching and revalidation). This gives each type of state the right tool with minimal overhead.
Q:What is state colocation and why is it important?
A: State colocation means keeping state as close to where it's used as possible. A search input's state should live in the SearchBar component, not in a global store. Benefits: (1) fewer re-renders (local state only re-renders one component), (2) easier to understand (state is next to the code that uses it), (3) easier to delete (remove the component, state goes with it). Only move state 'up' or 'out' when another component actually needs it.
Q:Compare Zustand and Redux Toolkit. When would you choose each?
A: Zustand: minimal boilerplate, no Provider wrapper, simple API (create + use hook), great for small-medium apps or when you want a lightweight global store. Redux Toolkit: more structured (slices, actions, reducers), powerful DevTools with time-travel debugging, middleware ecosystem (thunks, sagas), better for large teams that need strict conventions. Choose Zustand for simplicity, Redux for structure and tooling at scale.
Cheat Sheet
Quick-reference decision rules for state management.
Quick Revision Cheat Sheet
Default choice: useState — start local, move up only when needed
Two siblings need same state: Lift state to nearest common parent
Complex local transitions: useReducer — state machine in one component
Global + rarely changes: Context API (theme, auth, locale)
Global + changes often: Zustand or Redux (cart, notifications, UI prefs)
API data: React Query / SWR — never put in Redux or Zustand
Context re-render problem: All consumers re-render on any change — split contexts or use Zustand
Zustand vs Redux: Zustand = simple, no Provider. Redux = structured, DevTools, large teams
Server state concerns: Loading, error, caching, staleness, revalidation, deduplication
Client state concerns: Synchronous, always fresh, no caching needed
State colocation: Keep state as close to where it's used as possible
Derived state: Never store what you can compute — use useMemo or compute on render
Selector subscriptions: Zustand/Redux let components subscribe to specific slices — prevents re-render storms
Modern stack: React Query (server) + Zustand (global client) + useState (local)