Senior Frontend Interview Guide, Part 7: State Management: Zustand, Context, and Asynchronous Data
"How do you manage state?" is one of the most common senior frontend interview questions, and one of the easiest to answer badly. A weak answer names a library. A strong answer first classifies the kinds of state an application has, explains which tool fits each kind, and then discusses trade-offs…

This article is Part 7 of 18 in The Senior Frontend Engineer Interview Guide, and includes 22 interview questions with model answers.
“How do you manage state?” is one of the most common senior frontend interview questions, and one of the easiest to answer badly. A weak answer names a library. A strong answer first classifies the kinds of state an application has, explains which tool fits each kind, and then discusses trade-offs such as re-render performance, testability, and how state flows across micro frontends. This chapter gives you that framework, then goes deep on React Context, Zustand, and asynchronous data handling.
Kinds of State
| Kind | Examples | Typical home |
|---|---|---|
| Local UI state | Is this dropdown open, current input text | useState/useReducer in the component |
| Shared UI state | Sidebar collapsed, selected account across panels, wizard progress | Lifted state, Context, or a client store (Zustand) |
| Global app state | Current user, locale, theme, feature flags, permissions | Context or a store, initialized at startup |
| Server state (cache) | Accounts, transactions, payees fetched from APIs | TanStack Query, SWR, RTK Query, Apollo |
| URL state | Filters, sort order, pagination, selected tab | The router (path and query string) |
| Form state | Field values, touched, errors, submission status | Form library (React Hook Form) or local state |
| Persistent client state | Draft transfer, dismissed banners, preferences | Store with persistence (localStorage, IndexedDB) |
The most important distinction is between client state (owned by the browser, synchronous, always up to date) and server state (owned by the backend, asynchronous, can become stale, shared with other users and devices). Many applications that “need Redux” actually need a server-state cache plus a little client state.
A useful heuristic is to put state as close as possible to where it is used and to move it up only when sharing requires it.
React Context in Depth
Context is a dependency injection mechanism, not a state management library. It makes a value available to a subtree. State still lives in a component (with useState or useReducer), and Context distributes it.
A Well-Structured Context
const AuthStateContext = createContext(undefined);
const AuthActionsContext = createContext(undefined);
export function AuthProvider({ children, authClient }) {
const [state, dispatch] = useReducer(authReducer, { status: 'checking', user: null });
useEffect(() => {
authClient.getSession().then(
user => dispatch({ type: user ? 'signedIn' : 'signedOut', user }),
() => dispatch({ type: 'signedOut' })
);
}, [authClient]);
const actions = useMemo(() => ({
signIn: async credentials => {
const user = await authClient.signIn(credentials);
dispatch({ type: 'signedIn', user });
},
signOut: async () => {
await authClient.signOut();
dispatch({ type: 'signedOut' });
},
}), [authClient]);
return (
<AuthActionsContext.Provider value={actions}>
<AuthStateContext.Provider value={state}>{children}</AuthStateContext.Provider>
</AuthActionsContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthStateContext);
if (ctx === undefined) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}
export function useAuthActions() {
const ctx = useContext(AuthActionsContext);
if (ctx === undefined) throw new Error('useAuthActions must be used inside AuthProvider');
return ctx;
}
Good practices visible here:
- A custom hook per context with a helpful error when used outside its provider.
- Separate state and actions contexts, so components that only trigger actions (a sign-out button) do not re-render when state changes.
- Memoized values so the provider does not re-render consumers when its own parent re-renders.
- Dependencies (like
authClient) injected via props, which makes testing easy.
Context Performance
Every component that calls useContext(SomeContext) re-renders whenever the provider’s value changes by reference. There is no built-in way to subscribe to only part of a context value. Consequences:
- Do not put rapidly changing values (mouse position, form input on every keystroke) in a context used by many components.
- Split contexts by update frequency and concern.
- Memoize provider values.
- When you need selective subscriptions, use an external store such as Zustand with selectors, or
use-context-selector.
When Context Is the Right Tool
Context is ideal for values that change rarely and are needed widely: theme, locale, current user, feature flags, permissions, and injected services (API client, analytics, logger). It is also the backbone of compound components. It becomes awkward for high-frequency updates and large, frequently changing state graphs.
Zustand
Zustand (“state” in German) is a small, unopinionated store library built on hooks. Its store lives outside React, and components subscribe to slices of it with selectors. It uses useSyncExternalStore internally, so it is safe for React 18 concurrent rendering.
Creating a Store
import { create } from 'zustand';
export const useTransferStore = create((set, get) => ({
fromAccountId: null,
toPayeeId: null,
amountCents: 0,
memo: '',
step: 'details', // 'details' | 'review' | 'submitting' | 'done'
setField: (name, value) => set({ [name]: value }),
goToReview: () => {
const { fromAccountId, toPayeeId, amountCents } = get();
if (!fromAccountId || !toPayeeId || amountCents <= 0) return false;
set({ step: 'review' });
return true;
},
reset: () => set({ fromAccountId: null, toPayeeId: null, amountCents: 0, memo: '', step: 'details' }),
}));
set merges the object you pass into the state (shallowly, at the top level). get reads the current state inside actions.
Using Selectors
function AmountField() {
const amountCents = useTransferStore(s => s.amountCents);
const setField = useTransferStore(s => s.setField);
return <MoneyInput valueCents={amountCents} onChange={v => setField('amountCents', v)} />;
}
A component re-renders only when the value returned by its selector changes (compared with Object.is). AmountField does not re-render when memo changes.
Selecting multiple values with a new object each time would cause a re-render on every store update, because the object is always new. Use useShallow (Zustand v4.5+/v5) to compare shallowly:
import { useShallow } from 'zustand/react/shallow';
const { step, amountCents } = useTransferStore(
useShallow(s => ({ step: s.step, amountCents: s.amountCents }))
);
In Zustand v5, returning a new object from a selector without useShallow can cause an infinite render loop warning, so this pattern matters.
Updating Nested State Immutably
set only merges the top level. For nested updates, spread or use the Immer middleware:
import { immer } from 'zustand/middleware/immer';
const usePrefs = create(immer(set => ({
notifications: { email: true, sms: false, push: { enabled: true, quietHours: null } },
toggleSms: () => set(state => { state.notifications.sms = !state.notifications.sms; }),
})));
Middleware
| Middleware | Purpose |
|---|---|
devtools |
Connects to Redux DevTools for time-travel and action inspection |
persist |
Saves state to localStorage, sessionStorage, or a custom storage (IndexedDB), with versioning and migrations |
immer |
Write “mutating” updates that produce immutable state |
subscribeWithSelector |
Subscribe outside React to specific slices with change detection |
combine |
Infer types by separating initial state from actions |
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
export const usePreferences = create(
devtools(
persist(
set => ({
theme: 'system',
dismissedBanners: [],
setTheme: theme => set({ theme }, false, 'preferences/setTheme'),
dismiss: id => set(s => ({ dismissedBanners: [...s.dismissedBanners, id] })),
}),
{
name: 'preferences',
version: 2,
storage: createJSONStorage(() => localStorage),
partialize: s => ({ theme: s.theme, dismissedBanners: s.dismissedBanners }),
migrate: (persisted, version) => (version < 2 ? { ...persisted, theme: 'system' } : persisted),
}
),
{ name: 'PreferencesStore' }
)
);
Security note: never persist tokens, account numbers, or other sensitive data in localStorage. It is readable by any script running on the page, so an XSS vulnerability exposes it. partialize lets you persist only safe fields.
Slices Pattern for Large Stores
type AccountsSlice = { selectedAccountId: string | null; selectAccount: (id: string) => void };
type UiSlice = { sidebarOpen: boolean; toggleSidebar: () => void };
const createAccountsSlice: StateCreator<AccountsSlice & UiSlice, [], [], AccountsSlice> = set => ({
selectedAccountId: null,
selectAccount: id => set({ selectedAccountId: id }),
});
const createUiSlice: StateCreator<AccountsSlice & UiSlice, [], [], UiSlice> = set => ({
sidebarOpen: true,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
});
export const useAppStore = create<AccountsSlice & UiSlice>()((...a) => ({
...createAccountsSlice(...a),
...createUiSlice(...a),
}));
Many teams prefer several small, independent stores (one per feature) over one large store, which keeps features decoupled, especially in micro frontends.
Using the Store Outside React
const unsubscribe = useTransferStore.subscribe(
state => analytics.track('transfer_step', { step: state.step })
);
useTransferStore.getState().reset();
useTransferStore.setState({ memo: 'Rent' });
This makes Zustand convenient for integrating with non-React code: Axios interceptors, WebSocket handlers, or event buses.
Testing Zustand Stores
Stores are plain functions, so you can test actions directly:
beforeEach(() => useTransferStore.setState(useTransferStore.getInitialState()));
test('goToReview requires amount', () => {
useTransferStore.setState({ fromAccountId: 'a1', toPayeeId: 'p1', amountCents: 0 });
expect(useTransferStore.getState().goToReview()).toBe(false);
expect(useTransferStore.getState().step).toBe('details');
});
Because the store is a module-level singleton, reset it between tests (getInitialState is available in recent versions), or follow the official guidance of mocking zustand to reset all stores automatically.
Store Per Instance with Context
A module-level store is shared by the entire app. When you need one store per component instance (two independent transfer widgets on one page) or per request in server rendering, create the store with createStore and provide it through Context:
import { createStore, useStore } from 'zustand';
const WidgetStoreContext = createContext(null);
function WidgetProvider({ children, initial }) {
const [store] = useState(() => createStore(set => ({ ...initial, set })));
return <WidgetStoreContext.Provider value={store}>{children}</WidgetStoreContext.Provider>;
}
function useWidgetStore(selector) {
const store = useContext(WidgetStoreContext);
return useStore(store, selector);
}
This combines Context’s scoping with Zustand’s selective subscriptions.
Zustand versus Redux Toolkit versus Context
| Context + useReducer | Zustand | Redux Toolkit | |
|---|---|---|---|
| Boilerplate | Low | Very low | Moderate |
| Selective re-renders | No (whole value) | Yes (selectors) | Yes (selectors) |
| DevTools | No | Via middleware | Built in |
| Middleware ecosystem | None | Small, focused | Large (RTK Query, listeners) |
| Structure enforcement | None | Minimal | Strong conventions (slices, actions) |
| Use outside React | No | Yes | Yes |
| Bundle size | 0 | About 1 KB | About 10+ KB with React-Redux |
Zustand suits teams that want simplicity and performance without ceremony. Redux Toolkit suits very large teams that benefit from strict conventions and its tooling. Context alone suits low-frequency global values.
Asynchronous Data Handling
The States of a Request
Every async operation has at least these states: idle, loading, success (with data), and error (with an error). Real applications add more: refetching in the background while showing stale data, empty results, partial data, and cancelled requests. Model them explicitly:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
A discriminated union makes impossible combinations (loading with an error and data) unrepresentable.
A Minimal useAsync Hook
function useAsync(asyncFn, deps) {
const [state, setState] = useState({ status: 'idle' });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading' });
asyncFn(controller.signal)
.then(data => setState({ status: 'success', data }))
.catch(error => {
if (!controller.signal.aborted) setState({ status: 'error', error });
});
return () => controller.abort();
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
return state;
}
This works, but it lacks caching, de-duplication (two components requesting the same data trigger two requests), retries, background refetching, and invalidation after mutations. That is why server-state libraries exist.
TanStack Query (React Query)
TanStack Query manages server state as a cache keyed by query keys.
function useTransactions(accountId, filters) {
return useQuery({
queryKey: ['accounts', accountId, 'transactions', filters],
queryFn: ({ signal }) =>
api.get(`/accounts/${accountId}/transactions`, { params: filters, signal })
.then(r => r.data),
staleTime: 30_000,
placeholderData: keepPreviousData,
enabled: Boolean(accountId),
});
}
function TransactionsPanel({ accountId, filters }) {
const { data, isPending, isError, error, isFetching } = useTransactions(accountId, filters);
if (isPending) return <TableSkeleton />;
if (isError) return <ErrorState error={error} />;
return <TransactionTable rows={data.items} refreshing={isFetching} />;
}
Key concepts:
- Query keys identify data; different filters produce different cache entries.
staleTimecontrols how long data is considered fresh (no refetch).gcTime(formerlycacheTime) controls how long unused data stays in memory.- Automatic refetching on window focus, network reconnect, and mount when stale.
- Request de-duplication: simultaneous requests for the same key share one network call.
- Retries with exponential backoff (default three for queries).
- Pagination and infinite queries (
useInfiniteQuery) for “load more” transaction lists. - Prefetching (
queryClient.prefetchQuery) on hover or route transitions.
Mutations and Cache Invalidation
function useCreatePayee() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: payee => api.post('/payees', payee).then(r => r.data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payees'] }),
});
}
After a successful mutation, invalidating related queries marks them stale and refetches active ones. For low-risk updates, optimistic updates can be implemented with onMutate (save previous cache, write optimistic data), onError (roll back), and onSettled (invalidate).
Mutations are not retried by default, which is correct for non-idempotent operations like payments. If you enable retries for a payment submission, you must use an idempotency key (Chapter 8 and Chapter 13).
Where Zustand and TanStack Query Meet
A common, clean architecture:
- TanStack Query owns all server data.
- Zustand owns client state: UI preferences, selections, wizard steps, drafts.
- Context provides dependencies: API client, auth client, feature flags, i18n.
- The URL owns shareable view state: filters, sort, pagination.
Avoid copying server data into Zustand; you would have to reinvent invalidation and staleness. Store IDs (such as selectedAccountId) in Zustand and read the corresponding data from the query cache.
Real-Time Data
Balances and payment statuses may update in real time over WebSockets or Server-Sent Events. Integrate them by updating the query cache when messages arrive:
socket.on('payment.status', msg => {
queryClient.setQueryData(['payments', msg.paymentId], old =>
old ? { ...old, status: msg.status } : old);
queryClient.invalidateQueries({ queryKey: ['accounts', msg.accountId, 'balance'] });
});
Handle reconnection, missed messages (refetch on reconnect), message ordering (ignore out-of-order updates using sequence numbers or timestamps), and back-pressure.
Error Handling Strategy
- Distinguish error types: network errors (retry, offline banner), 4xx validation errors (show field messages), 401 (refresh token or re-authenticate), 403 (permission message), 5xx (retry with backoff, then a friendly error), and timeouts.
- Show errors where they happen: inline for a widget, a toast for a background action, a full page for a route that cannot load.
- Log with context: request ID, route, user action, and app version, without sensitive data.
- Global handlers via the QueryClient’s
QueryCacheandMutationCacheonErrorcallbacks.
Race Conditions and Ordering
When requests can complete out of order (typing quickly in a search box), the last response to arrive may not be for the last request sent. Solutions: abort previous requests, compare a request ID before applying results, or let a query library key results by parameters so each result lands in its own cache entry.
State and Micro Frontends
Each micro frontend should own its own state. Shared state across micro frontends should be minimal and exposed by the shell through a narrow, versioned interface: current user, locale, permissions, perhaps a selected account. Zustand works well here because a store can live in a shared module (federated as a singleton) and be read with selectors, but every shared store is a coupling point. Events (Chapter 4) are often a better fit for cross-MFE notifications.
Interview Questions and Answers
Q1. How do you decide where to put a piece of state?
Classify it. Local UI state stays in the component. Shared UI state is lifted to the nearest common ancestor, or placed in a client store if far apart. Server data goes into a server-state cache like TanStack Query. Shareable view state belongs in the URL. Form state goes into a form library. I move state up only when sharing requires it.
Q2. Is React Context a state management solution?
Not by itself. Context is a dependency injection mechanism that passes a value through the tree. The state still lives in a component with useState or useReducer. Context lacks selective subscriptions, so it is best for values that change infrequently, like theme, locale, user, and injected services.
Q3. Why do all context consumers re-render, and how do you mitigate it?
useContext subscribes a component to the provider’s entire value, and React re-renders all consumers when the value’s reference changes. Mitigate by memoizing the value, splitting state and actions or unrelated concerns into separate contexts, moving frequently changing state elsewhere, or using a store with selectors.
Q4. Why choose Zustand?
It is tiny, needs almost no boilerplate, supports selective subscriptions with selectors so components only re-render when their slice changes, works outside React, is safe with concurrent rendering through useSyncExternalStore, and has useful middleware for persistence, DevTools, and Immer. It suits teams that want a simple global store without Redux ceremony.
Q5. How does Zustand decide when to re-render a component?
Each component subscribes with a selector. When the store changes, Zustand runs the selector and compares the result to the previous one with Object.is (or a custom equality such as shallow with useShallow). Only if the result changed does the component re-render.
Q6. What is the problem with useStore(s => ({ a: s.a, b: s.b }))?
The selector returns a new object on every call, so the result is never Object.is equal to the previous one, and the component re-renders on every store change (and Zustand v5 can warn about infinite loops). Use separate selectors, or wrap it with useShallow to compare shallowly.
Q7. How do you persist Zustand state safely?
Use the persist middleware with partialize to save only non-sensitive fields, a version number and migrate function for schema changes, and the appropriate storage (sessionStorage for per-tab state). Never persist tokens or PII to web storage, and handle hydration timing for SSR.
Q8. Zustand or Redux Toolkit for a large enterprise app?
It depends on team size and need for conventions. Redux Toolkit provides strong structure, excellent DevTools, RTK Query, and listener middleware, which help large teams stay consistent. Zustand is lighter and faster to work with. In either case I would keep server data in a dedicated cache (RTK Query or TanStack Query) and keep the client store small.
Q9. What is server state, and why treat it differently?
Data owned by a backend and fetched asynchronously. It can become stale, is shared with other clients, needs caching, de-duplication, refetching, pagination, and invalidation after mutations. General-purpose client stores do not handle these concerns, so libraries like TanStack Query or SWR are a better fit.
Q10. Explain staleTime and gcTime in TanStack Query.
staleTime is how long fetched data is considered fresh; during this period it is served from cache without refetching. gcTime is how long inactive data (no observers) stays in the cache before garbage collection. Fresh data avoids refetches; cached stale data still renders instantly while a background refetch runs.
Q11. How do you keep the UI consistent after a mutation?
Invalidate the affected queries so they refetch, or update the cache directly with the mutation’s response using setQueryData. For instant feedback on low-risk actions, apply an optimistic update with rollback on error. For payments, show a pending state until confirmed.
Q12. How do you handle race conditions in data fetching?
Abort outdated requests with AbortController, ignore responses whose request is no longer current (via an ignore flag or request ID), or key results by request parameters as query libraries do. Debounce rapid inputs and use sequence numbers for real-time messages.
Q13. How would you implement infinite scrolling of transactions?
Use cursor-based pagination from the API, useInfiniteQuery with getNextPageParam returning the next cursor, an IntersectionObserver sentinel to call fetchNextPage, list virtualization for large lists, and an accessible “Load more” button as an alternative to automatic loading. Announce new results to screen readers politely.
Q14. Where should filter and sort state live?
In the URL query string when users should be able to bookmark, share, or use the back button with it, which is usually the case for transaction filters. Read them with the router, and derive the query key from them. Local state is acceptable for ephemeral UI-only filtering.
Q15. How do you test components that use a Zustand store?
Reset the store to its initial state before each test, set up the scenario with setState, render the component, and assert on the UI. Test store actions directly with getState(). For per-instance stores provided via Context, render inside a provider with the desired initial state.
Q16. How do you integrate real-time updates with cached server data?
Subscribe to WebSocket or SSE messages in a provider, update the query cache with setQueryData for precise updates, or invalidate affected queries to refetch. Handle reconnection by refetching, ignore out-of-order messages using version numbers, and throttle UI updates under high message volume.
Q17. What are the dangers of duplicating server data in a client store?
Two sources of truth that drift apart, manual and error-prone invalidation, stale data after mutations or in other tabs, and more code. Store identifiers in the client store and read the data from the server cache.
Q18. How do you share state between micro frontends?
Minimize it. Share only stable cross-cutting state (user, locale, permissions) through the shell, via props, a small singleton store, or a context exposed by the shell. Use events for notifications, the URL for navigation state, and let each micro frontend own its domain data.
Q19. How do you handle loading states without layout shift and flicker?
Show skeletons with the final dimensions, delay spinners for very fast responses (for example 200 ms) to avoid flashes, keep previous data visible during refetches (placeholderData: keepPreviousData or transitions), and use Suspense boundaries at sensible granularity.
Q20. What is useSyncExternalStore, and why does Zustand use it?
It is a React 18 hook for subscribing to external stores in a way that is consistent under concurrent rendering, preventing tearing. Zustand uses it so all components see the same snapshot of the store during a render.
Q21. When would you use useReducer with Context instead of a library?
For moderately complex state that is scoped to one feature or subtree, changes infrequently or is consumed by few components, and where avoiding a dependency is valuable. Examples are a multi-step form wizard or a settings page. If many components need selective subscriptions, a store is better.
Q22. How do you design error handling for API calls in a large app?
Normalize errors in the HTTP client into typed error objects (network, timeout, validation, auth, permission, server). Handle authentication centrally with refresh logic, surface validation errors inline, use retries with backoff for transient failures, show contextual error UI with recovery actions, and log errors with correlation IDs. Global query cache handlers catch anything unhandled.
Key Takeaways
- Classify state first: local, shared UI, global, server, URL, form, persistent.
- Context is for dependency injection and low-frequency global values; split and memoize it.
- Zustand offers simple stores with selector-based subscriptions, middleware, and use outside React.
- Use a server-state library for API data; never duplicate server data in client stores.
- Model async states explicitly and handle races, retries, and errors deliberately.
← Part 6: CSS Modules and Reusable UI Components · All chapters · Part 8: REST APIs, Web Services, and Axios →







