React State Management in 2025
The most common senior React interview question: "How do you manage state in a large application?" Here's the complete answer.
The Four Layers of State
Before choosing a tool, identify what type of state you're dealing with:
| Layer | What | Tool | |---|---|---| | Local UI state | Form inputs, toggles, accordion open/closed | useState | | Lifted state | Shared between 2-3 siblings | useState in common ancestor | | Global app state | Current user, theme, cart | Context or external store | | Server/remote state | API data, loading, errors | React Query / SWR |
Most applications are simpler than developers think — start with useState, lift when needed.
Layer 1: useState — Your Default Choice
Use it for anything a single component needs to track.
function SearchBar() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
// When multiple fields update together, consider useReducer async function search(q) { setLoading(true) const data = await fetchResults(q) setResults(data) setLoading(false) }
return <input value={query} onChange={e => { setQuery(e.target.value); search(e.target.value) }} /> }
Layer 2: useReducer — Predictable Complex State
Switch from useState to useReducer when: 1. Multiple state fields update together based on the same action 2. Next state depends on the current state and action type 3. State logic is complex enough to test independently
const initialState = { count: 0, step: 1, history: [] }
function reducer(state, action) { switch (action.type) { case 'INCREMENT': return { ...state, count: state.count + state.step, history: [...state.history, state.count] } case 'SET_STEP': return { ...state, step: action.payload } case 'RESET': return initialState default: return state } }
function Counter() { const [state, dispatch] = useReducer(reducer, initialState) return ( <div> <button onClick={() => dispatch({ type: 'INCREMENT' })}>+{state.step}</button> <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button> </div> ) }
useReducer is especially valuable when state transitions are complex — the reducer is a pure function you can unit test without React.
Layer 3: Context API — Global State Without Prop Drilling
Context broadcasts a value to any descendant component that subscribes.
const UserContext = createContext(null)
export function UserProvider({ children }) { const [user, setUser] = useState(null)
// Memoize the value object — prevents all consumers from re-rendering // when the Provider's parent re-renders for unrelated reasons const value = useMemo(() => ({ user, setUser }), [user])
return <UserContext.Provider value={value}>{children}</UserContext.Provider> }
export function useUser() { const ctx = useContext(UserContext) if (!ctx) throw new Error('useUser must be used inside UserProvider') return ctx }
// Any descendant can now use it: function Avatar() { const { user } = useUser() return <img src={user?.avatar} /> }
The Context re-render problem: Every component that calls useContext re-renders when the context value changes. If you put too much in a single Context (user + cart + theme), a theme change re-renders every component subscribed to the context — including those that only need the user.
Solution: Split context by update frequency. One context per domain.
Layer 4: External Stores (Zustand, Redux Toolkit)
Reach for an external store when:
- Multiple teams or many developers need shared state
- State has complex interdependencies or many slices
- You need time-travel debugging, Redux DevTools
- Server and client state need a clear boundary
Zustand — minimal boilerplate:
import { create } from 'zustand'
const useCartStore = create((set) => ({ items: [], add: (item) => set(state => ({ items: [...state.items, item] })), remove: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })), }))
function Cart() { const { items, remove } = useCartStore() return items.map(item => <CartItem key={item.id} item={item} onRemove={() => remove(item.id)} />) }
Components subscribe to slices of the store — only re-render when the specific slice they use changes.
Redux Toolkit — structured, mature ecosystem: Best for large teams, existing Redux codebases, or when you need the full tooling (DevTools, middleware, normalization).
Server State: React Query
API data is fundamentally different from client state — it goes stale, needs caching, loading states, and background refetching. useState is the wrong tool.
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch('/api/users/' + userId).then(r => r.json()),
staleTime: 5 60 1000, // fresh for 5 minutes
})
if (isLoading) return <Spinner /> if (error) return <Error message={error.message} /> return <Profile user={user} /> }
React Query handles: caching, deduplication, background updates, pagination, and optimistic updates — in about 10 lines.
Decision Tree
1. Is it only needed by one component? → useState 2. Needed by a few siblings? → Lift state to common parent 3. Needed across many components? → Context (small app) or external store (large app) 4. Is it data from an API? → React Query or SWR, not useState
The right answer to "how do you manage state" isn't a single tool — it's using the right layer for each type of state.
Practice state management questions at [JSPrep Pro](/auth).