State Management interview questions
Types of State
Understanding where state lives is the foundation of good frontend architecture.
Local state
Belongs to a single component. Not shared.
tsxfunction Counter() { const [count, setCount] = useState(0); // local return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }Global (client) state
Shared across many components. Managed by a store.
tsx// auth, theme, shopping cart, notifications const user = useAuthStore(s => s.user); const theme = useThemeStore(s => s.theme);Server state
Data that lives on the server - fetched, cached, and synchronized.
tsx// React Query / SWR handle this const { data: posts, isLoading } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts, });URL state
State encoded in the URL - shareable and bookmarkable.
tsx// search filters, pagination, active tab // /products?category=shoes&sort=price&page=2 const [searchParams, setSearchParams] = useSearchParams(); const category = searchParams.get('category');Where each belongs
Type Examples Tool Local Modal open, form input, toggle useState,useReducerGlobal Auth, theme, cart Zustand, Jotai, Redux Server API data, lists, user profile React Query, SWR URL Filters, pagination, tabs useSearchParams, routerOne of the most common architecture mistakes is storing server state in a global store (Redux). React Query / SWR exist specifically to handle server state - let them do it.
updated
did you know it?Redux
Redux is a predictable state container based on three core principles.
Three principles
- Single source of truth - one global store.
- State is read-only - only actions can trigger changes.
- Changes via pure functions - reducers are pure.
Data flow
textUI -> dispatch(action) -> reducer(state, action) -> new state -> UICore pieces
ts// 1 - action const increment = { type: 'counter/increment', payload: 1 }; // 2 - reducer function counterReducer(state = 0, action) { switch (action.type) { case 'counter/increment': return state + action.payload; default: return state; } } // 3 - store (createStore is deprecated - shown only to explain the idea) const store = createStore(counterReducer); store.dispatch(increment); store.getState(); // 1Redux Toolkit (modern Redux)
tsimport { createSlice, configureStore } from '@reduxjs/toolkit'; const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1; }, // immer - looks mutable decrement: (state) => { state.value -= 1; }, incrementBy: (state, action) => { state.value += action.payload; }, }, }); export const { increment, decrement, incrementBy } = counterSlice.actions; const store = configureStore({ reducer: { counter: counterSlice.reducer } });In components
tsx// typed hooks: useAppSelector = useSelector.withTypes<RootState>() const value = useAppSelector(s => s.counter.value); const dispatch = useAppDispatch(); <button onClick={() => dispatch(increment())}>+</button>Redux solves predictability and debuggability - the Redux DevTools let you time-travel through every state change. For most modern apps Zustand or Jotai is simpler, but Redux still dominates in large enterprise codebases.
updated
did you know it?Zustand
Zustand is a minimal, unopinionated state management library for React. It provides a store with no boilerplate.
Creating a store
tsimport { create } from 'zustand'; interface CounterStore { count: number; increment: () => void; decrement: () => void; reset: () => void; } const useCounterStore = create<CounterStore>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }), }));Using in components
tsxfunction Counter() { // subscribe to only what you need - no unnecessary re-renders const count = useCounterStore((s) => s.count); const increment = useCounterStore((s) => s.increment); return <button onClick={increment}>{count}</button>; }Async actions
tsconst useUserStore = create<UserStore>((set) => ({ user: null, loading: false, fetchUser: async (id) => { set({ loading: true }); const user = await api.getUser(id); set({ user, loading: false }); }, }));Zustand vs Redux
Redux Toolkit Zustand Boilerplate Medium Minimal Bundle size ~11kb ~1kb DevTools Excellent Good Learning curve Steep Very low Async Thunk/Saga Built-in Architecture Opinionated Flexible Best for Large teams Small-medium apps Zustand's selector pattern (
useStore(s => s.count)) is key - components only re-render when the selected slice changes, not the whole store.updated
did you know it?React Query
React Query (TanStack Query) is a server state manager - it handles the full lifecycle of asynchronous data: fetching, caching, synchronizing, and updating.
The problem it solves
Without React Query, server data management requires lots of manual work:
tsx// manual approach - what you have to write yourself const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); fetch('/api/users') .then(r => r.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, []); // missing: caching, deduplication, background refetch, stale data...With React Query
tsxconst { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: () => fetch('/api/users').then(r => r.json()), staleTime: 5 * 60 * 1000, // data is fresh for 5 minutes });What it handles automatically
- Caching and deduplication of identical requests.
- Background refetching when window is refocused.
- Automatic retries on failure.
- Pagination and infinite scroll.
- Optimistic updates.
- Cache invalidation.
Mutations
tsxconst mutation = useMutation({ mutationFn: (newUser) => fetch('/api/users', { method: 'POST', body: JSON.stringify(newUser), }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); // refetch list }, }); mutation.mutate({ name: 'Alice', email: 'alice@example.com' });Why it's not a traditional state manager
Redux / Zustand React Query State type Client state Server state Source of truth The store The server Writes Direct Mutations + invalidation Caching Manual Built-in React Query doesn't replace Zustand or Redux - it replaces the API data slices you used to put inside them. Use both: React Query for server data, Zustand for client state.
updated
did you know it?Jotai and Atomic State
Jotai implements the atomic state model - state is broken into small, independent units called atoms.
Basic atoms
tsimport { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; // define atoms const countAtom = atom(0); const nameAtom = atom('Alice'); const darkModeAtom = atom(false);Using atoms
tsxfunction Counter() { const [count, setCount] = useAtom(countAtom); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; } function Display() { const count = useAtomValue(countAtom); // read only return <span>{count}</span>; } function ResetButton() { const setCount = useSetAtom(countAtom); // write only - no re-render on change return <button onClick={() => setCount(0)}>Reset</button>; }Derived atoms
tsconst cartAtom = atom([{ id: 1, price: 10 }, { id: 2, price: 25 }]); // derived - recalculates when cartAtom changes const totalAtom = atom((get) => { const cart = get(cartAtom); return cart.reduce((sum, item) => sum + item.price, 0); });Async atoms
tsconst userAtom = atom(async (get) => { const id = get(userIdAtom); const res = await fetch(`/api/users/${id}`); return res.json(); }); // use with Suspense function UserProfile() { const user = useAtomValue(userAtom); // suspends until resolved return <div>{user.name}</div>; }Jotai vs Zustand vs Redux
Redux Zustand Jotai Model Single store Single store Atoms Bundle size ~11kb ~1kb ~3kb Re-renders Selector-based Selector-based Atom subscription Code splitting Manual Manual Automatic Best for Large teams General use Fine-grained reactivity Jotai shines when you have many small, independent pieces of state that components subscribe to individually - each component only re-renders when its exact atoms change.
updated
did you know it?Selectors
A selector is a function that extracts and derives a specific piece of data from the store. They are critical for preventing unnecessary re-renders.
Without selectors - everything re-renders
tsx// component re-renders on ANY store change const store = useStore(); const username = store.user.name;With selectors - targeted subscriptions
tsx// re-renders ONLY when user.name changes const username = useStore(s => s.user.name); // re-renders ONLY when cart length changes const cartCount = useStore(s => s.cart.items.length);Derived selectors with memoization
tsimport { create } from 'zustand'; import { createSelector } from 'reselect'; // or write manually const selectCart = (s: Store) => s.cart.items; const selectDiscount = (s: Store) => s.cart.discount; // reselect - only recalculates when inputs change const selectTotal = createSelector( [selectCart, selectDiscount], (items, discount) => { const subtotal = items.reduce((sum, item) => sum + item.price, 0); return subtotal * (1 - discount); } ); const total = useStore(selectTotal);Zustand - built-in shallow comparison
tsximport { useShallow } from 'zustand/react/shallow'; // subscribes to multiple values without re-rendering if references match const { name, email } = useStore( useShallow(s => ({ name: s.user.name, email: s.user.email })) );Redux - reselect
tsimport { createSelector } from '@reduxjs/toolkit'; export const selectCompletedTodos = createSelector( [(s: RootState) => s.todos], (todos) => todos.filter(t => t.completed) ); // result is memoized - list recalculates only when todos changesPoorly written selectors that return new object/array references on every call (
s => s.items.filter(...)) will cause components to re-render on every store update, even when nothing relevant changed.updated
did you know it?Optimistic UI
Optimistic UI means updating the interface immediately on user action - before the server confirms - then reconciling when the response arrives.
Why use it
- Feels instant to the user even on slow connections.
- Dramatically improves perceived performance.
- Used by Twitter/X, GitHub, Gmail for likes, reactions, toggles.
Manual implementation
tsxfunction TodoItem({ todo }) { const [optimisticDone, setOptimisticDone] = useState(todo.done); const toggle = async () => { setOptimisticDone(prev => !prev); // update UI immediately try { await api.updateTodo(todo.id, { done: !todo.done }); } catch { setOptimisticDone(todo.done); // rollback on error } }; return <input type="checkbox" checked={optimisticDone} onChange={toggle} />; }React Query - built-in optimistic updates
tsxconst queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (updatedTodo) => api.updateTodo(updatedTodo), onMutate: async (updatedTodo) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); // save current state for rollback const previous = queryClient.getQueryData(['todos']); // optimistically update the cache queryClient.setQueryData(['todos'], (old) => old.map(t => t.id === updatedTodo.id ? updatedTodo : t) ); return { previous }; // context for onError }, onError: (err, variables, context) => { // rollback on failure queryClient.setQueryData(['todos'], context.previous); }, onSettled: () => { // refetch to sync with server truth queryClient.invalidateQueries({ queryKey: ['todos'] }); }, });React 19 - useOptimistic
tsxconst [optimisticTodos, addOptimistic] = useOptimistic( todos, (state, newTodo) => [...state, { ...newTodo, pending: true }] );The key to good optimistic UI is a solid rollback strategy. Always save the previous state before mutating and restore it on error.
updated
did you know it?Context + useReducer
Combining
useReducerwithContextgives you a lightweight state management system without any external library.Implementation
tsx// types type State = { count: number; theme: 'light' | 'dark' }; type Action = | { type: 'increment' } | { type: 'toggle-theme' }; // reducer function reducer(state: State, action: Action): State { switch (action.type) { case 'increment': return { ...state, count: state.count + 1 }; case 'toggle-theme': return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' }; } } // context const StateCtx = createContext<State | null>(null); const DispatchCtx = createContext<React.Dispatch<Action> | null>(null); // provider export function AppProvider({ children }: { children: React.ReactNode }) { const [state, dispatch] = useReducer(reducer, { count: 0, theme: 'light' }); return ( <StateCtx.Provider value={state}> <DispatchCtx.Provider value={dispatch}> {children} </DispatchCtx.Provider> </StateCtx.Provider> ); } // hooks export const useAppState = () => useContext(StateCtx)!; export const useAppDispatch = () => useContext(DispatchCtx)!;Usage
tsxfunction Counter() { const { count } = useAppState(); const dispatch = useAppDispatch(); return <button onClick={() => dispatch({ type: 'increment' })}>{count}</button>; }When to use vs a library
Scenario Context + useReducer Zustand / Redux Small app or single feature Yes Overkill Complex update logic Yes Yes High-frequency updates No - re-renders all consumers Yes - selectors DevTools needed No Yes Team convention Depends Preferred Splitting state and dispatch into two contexts is important - components that only dispatch won't re-render when state changes.
updated
did you know it?State Normalization
Normalization means structuring nested/relational data as flat lookup tables, similar to a database.
The problem - nested state
ts// nested - hard to update const state = { posts: [ { id: '1', title: 'Hello', author: { id: 'u1', name: 'Alice', avatar: '...' }, comments: [ { id: 'c1', text: 'Nice!', author: { id: 'u2', name: 'Bob' } }, ] } ] }; // updating Alice's name requires finding her in every post and commentNormalized state - flat lookup tables
tsconst state = { users: { u1: { id: 'u1', name: 'Alice', avatar: '...' }, u2: { id: 'u2', name: 'Bob' }, }, posts: { '1': { id: '1', title: 'Hello', authorId: 'u1', commentIds: ['c1'] }, }, comments: { c1: { id: 'c1', text: 'Nice!', authorId: 'u2' }, }, }; // updating Alice's name - one place state.users.u1.name = 'Alicia';Redux Toolkit - createEntityAdapter
tsimport { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; const usersAdapter = createEntityAdapter<User>(); const usersSlice = createSlice({ name: 'users', initialState: usersAdapter.getInitialState(), reducers: { addUser: usersAdapter.addOne, updateUser: usersAdapter.updateOne, removeUser: usersAdapter.removeOne, setAllUsers: usersAdapter.setAll, }, }); // selectors come for free const { selectAll, selectById, selectIds } = usersAdapter.getSelectors( (state: RootState) => state.users );When to normalize
- The same entity appears in multiple places.
- You perform frequent updates to nested items.
- You render large lists that need fast lookups by ID.
- You're building a complex relational data model.
If your data comes from a REST API with nested relationships, normalization pays off quickly. For simple list display, it may be overkill.
updated
did you know it?Flux Architecture
Flux is an architectural pattern for building client-side applications, introduced by Facebook/Meta alongside React in 2014. Redux is its most famous implementation.
Core idea - unidirectional data flow
textAction -> Dispatcher -> Store -> View -> Action -> ...Data flows in one direction only - this makes state changes predictable and traceable.
The four parts
1 - Actions - plain objects describing what happened
ts{ type: 'ADD_TODO', payload: { text: 'Buy milk' } } { type: 'USER_LOGGED_IN', payload: { user } }2 - Dispatcher - central hub that receives actions and forwards them
ts// in Redux this is store.dispatch() dispatch({ type: 'ADD_TODO', payload: { text: 'Buy milk' } });3 - Store - holds state and update logic
ts// in Redux this is the reducer function reducer(state, action) { switch (action.type) { case 'ADD_TODO': return [...state, action.payload]; default: return state; } }4 - View - React components that read state and dispatch actions
tsxconst todos = useSelector(s => s.todos); <button onClick={() => dispatch(addTodo('Buy milk'))}>Add</button>Why unidirectional matters
text// MVC - hard to trace: anything can update anything Model <-> Controller <-> View Model <-> Model // Flux - easy to trace: one direction View -> Action -> Store -> ViewFlux vs MVC
MVC Flux Data flow Bidirectional Unidirectional Debugging Hard to trace Easy - follow actions State updates Anywhere Only in stores Predictability Low High Flux's key insight was that bidirectional data binding (as in early Angular/Backbone) becomes unmanageable at scale. Unidirectional flow with explicit actions makes state changes auditable and debuggable.
updated
did you know it?