⚛️ React Deep Dive
1 Global vs local state — and Context vs Redux vs TanStack Query
The single most important framing: there are two completely different kinds of "state," and most teams break their app by treating them as one.
| Server state | Client state |
| Data you fetched from an API. You don't own it — the server does. It can go stale. Needs caching, refetching, dedup, retries. |
Data the UI owns. Theme, "is sidebar open", the current cart, form input. Never stale — it's the truth the moment you set it. |
| → TanStack Query / SWR / RTK Query | → useState → Context → Zustand / Redux |
Architecture / LLD lensThe mistake that creates unmaintainable apps: dumping fetched API data into Redux. Now you're hand-writing loading flags, error flags, cache invalidation, and refetch logic for every endpoint — reinventing a cache, badly. Server-cache libraries exist precisely because "fetched data" is a different problem with different rules (staleness, background refetch, request dedup) than "UI state." Picking the right tool per category is the actual skill.
The decision ladder (climb only as high as you need)
- Local
useState — one component owns it. Default. Stay here until it hurts.
- Lift state up — two siblings need it → move it to their parent, pass props down.
- Context — many components deep in the tree need a low-frequency value (theme, locale, current user). Solves "prop drilling."
- Zustand / Redux — global client state that changes often and is read in many places, where Context would re-render too much.
- TanStack Query — the data came from a server. Different axis entirely; you'll often use it alongside one of the above.
Context — what it actually is, and its trap
Context is a dependency-injection / transport mechanism, not a state manager. It moves a value down the tree without prop drilling. It has no optimization built in.
// 1. Create
const ThemeContext = createContext('light');
// 2. Provide
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Dashboard />
</ThemeContext.Provider>
);
}
// 3. Consume — anywhere below, no prop drilling
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
The Context caveat that gets askedEvery component that calls useContext re-renders whenever the Provider's value changes — and a new object literal (value={{ theme, setTheme }}) is a new reference on every render. So if a high-frequency value (mouse position, form text) lives in Context, the entire subtree re-renders constantly.
Fix 1 — useMemo the value object so its reference is stable across renders that don't change its contents:
function App() {
const [theme, setTheme] = useState('light');
// ❌ Without useMemo: a NEW object every render → all consumers re-render
// const value = { theme, setTheme };
// ✅ Stable reference — only changes when `theme` actually changes
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}><Dashboard /></ThemeContext.Provider>;
}
Fix 2 — split into two contexts: the changing value and the stable setter. Components that only dispatch (a toggle button) subscribe to the setter context and never re-render when theme changes:
const ThemeValueContext = createContext(null); // changes often
const ThemeSetContext = createContext(null); // stable forever
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// setTheme from useState is referentially stable — no useMemo needed
return (
<ThemeSetContext.Provider value={setTheme}>
<ThemeValueContext.Provider value={theme}>{children}</ThemeValueContext.Provider>
</ThemeSetContext.Provider>
);
}
// ✅ Only re-renders when `theme` changes
function ThemeLabel() {
const theme = useContext(ThemeValueContext);
return <span>{theme}</span>;
}
// ✅ Subscribes to the SETTER only → NEVER re-renders when theme changes
function ThemeToggle() {
const setTheme = useContext(ThemeSetContext);
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>flip</button>;
}
Why Fix 2 is the stronger oneFix 1 stops re-renders caused by unrelated parent renders, but every consumer still re-renders when theme genuinely changes — even a button that only needs setTheme. Fix 2 separates "who reads the value" from "who triggers the change," so dispatch-only components opt out entirely. This is exactly the model selector stores like Zustand give you for free (Q1 table).
Zustand — when Context's re-render model breaks down
Zustand gives you a global store with selector-based subscriptions: a component re-renders only when the specific slice it selected changes — not the whole store. That's the thing Context can't do natively.
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
clear: () => set({ items: [] }),
}));
// This component re-renders ONLY when items.length changes —
// not when other parts of the store update.
function CartBadge() {
const count = useCartStore((s) => s.items.length);
return <span>{count}</span>;
}
Redux Toolkit — when you actually need it
Redux's value isn't "global variables." It's a strict, predictable update contract: state is read-only, the only way to change it is dispatching an action, and a pure reducer computes the next state. That gives you time-travel debugging, a single audit log of every change, and middleware (sagas, logging, analytics).
import { createSlice, configureStore } from '@reduxjs/toolkit';
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => { state.items.push(action.payload); }, // Immer = safe "mutation"
clear: (state) => { state.items = []; },
},
});
export const { addItem, clear } = cartSlice.actions;
export const store = configureStore({ reducer: { cart: cartSlice.reducer } });
Architecture / LLD lens — why the reducer pattern?Redux is the Command pattern + event-sourcing applied to UI state. Actions are commands ("ADD_ITEM"); the reducer is a pure function (state, action) => newState. Purity is what buys you the superpowers: replayable history, testability (no mocks — just call the reducer), and a single choke point where every state transition is observable. You pay for that with boilerplate, so it only earns its keep in large apps with complex, interdependent client state and many engineers who benefit from the enforced discipline.
TanStack Query — server state done right
Replaces the entire useEffect + useState fetch dance. You declare what data you want with a key; it handles caching, dedup, background refetch, retries, and loading/error states.
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId], // cache identity — same key = shared cache, deduped
queryFn: () => fetchUser(userId),
staleTime: 60_000, // fresh for 60s → no refetch on remount
});
// Mutations + cache invalidation:
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', userId] }), // refetch fresh
});
Why this beats useEffect fetchingHand-rolled useEffect fetching has no shared cache (two components = two requests), no dedup, no background refresh, no retry, and a race-condition bug if the user navigates fast (old response overwrites new). TanStack Query solves all of those by default. The queryKey is the cache identity — same key anywhere in the app reads the same cached data.
| Use | When |
| useState | One component owns it. |
| Context | Low-frequency value (theme/auth/locale) needed deep in the tree. Few writes. |
| Zustand | Global client state, frequent updates, need selective re-renders, want minimal boilerplate. |
| Redux Toolkit | Large app, complex interdependent state, many devs, need middleware / audit / time-travel. |
| TanStack Query | The data came from a server. Almost always. |
Say it out loud"I split state into server and client. Server state goes to TanStack Query — it's a cache, not state. Client state climbs a ladder: local, then Context for low-frequency values, then Zustand or Redux when Context would over-render. Context is dependency injection, not a state manager — that distinction drives most of my decisions."
★ TanStack Query — deep dive
The one idea everything hangs on: TanStack Query is an async cache, not a state manager. People install it thinking "Redux replacement" — wrong model. Its job is managing the lifecycle of data that lives somewhere else (a server) where you only hold a copy.
The core problem it solvesA local copy of remote data has a problem Redux/Zustand never have: it can go stale the moment you fetch it. Someone else edited the record; the price changed. Your copy is now a lie and you don't know it. Everything TanStack Query does serves that one problem: keep a local copy of remote data fresh, fast, and consistent — without rewriting the same boilerplate 50 times.
What you're actually replacing
The hand-rolled fetch everyone writes first is broken in six ways — and TanStack Query fixes all six by default. That's the real value: correctness you didn't know you were missing, not nicer syntax.
// The naive version — looks fine, isn't
function useUser(id) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${id}`).then(r => r.json())
.then(setData).catch(setError).finally(() => setLoading(false));
}, [id]);
return { data, loading, error };
}
| # | Bug in the naive version |
| 1 | No shared cache — two components calling useUser(5) = two requests for identical data. |
| 2 | Race condition — change id 5→6→5 fast, responses arrive out of order, you render the wrong user (no cancellation). |
| 3 | No background refresh — fetched once, then rots. Come back an hour later, still stale. |
| 4 | No retry — one network blip = error screen. |
| 5 | Refetches on every mount — navigate away and back = full reload + spinner flash, even though you had it 2s ago. |
| 6 | No dedup — 5 widgets needing the same data on one screen = 5 requests. |
Mechanic 1 — the query key is cache identity
const { data, isLoading, error } = useQuery({
queryKey: ['user', id], // ← the cache address
queryFn: () => fetchUser(id),
});
Same key anywhere in the app = same cached entry. Mount this in 10 components with ['user', 5] → one network request, one shared result, all 10 update together. That's automatic dedup + sharing, and it's the single most important concept. Keys are hierarchical and serializable — ['todos', { status: 'done', page: 2 }] — so you can invalidate broadly (['todos'] kills every todo query) or narrowly.
Mechanic 2 — staleTime vs gcTime (the #1 confusion)
| staleTime | gcTime (was cacheTime) |
| Answers | "Is my data fresh enough to NOT refetch?" | "How long do I keep unused data in memory?" |
| Default | 0 (instantly stale) | 5 minutes |
| Controls | when a background refetch triggers | when data is garbage-collected after no component uses it |
useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 60_000, // trust this data 60s — no refetch on remount within window
gcTime: 5 * 60_000, // keep it 5min after the LAST component unmounts
});
The staleTime trapPeople leave staleTime: 0 (default) on data that barely changes — a user profile, app config — then wonder why it refetches on every single navigation. Default behavior: serve cached value instantly AND refetch in background (stale-while-revalidate). Fix for stable data: bump staleTime (e.g. staleTime: 1000 * 60 * 5 for a profile that changes monthly). That one knob is most of "how do I tune this."
Mechanic 3 — refetch triggers (free freshness)
By default it refetches on window refocus, network reconnect, component remount, and key change. The refocus one is the magic: alt-tab away, come back, data silently refreshes. All configurable per-query or globally.
Mechanic 4 — mutations + invalidation (the write path)
Queries read; mutations write, then tell the cache what's now stale.
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (updates) => api.updateUser(id, updates),
onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }), // mark stale → auto-refetch
});
mutation.mutate({ name: 'New Name' });
invalidateQueries is the heart of write-then-read consistency: you changed the server, so the cached copy is a lie — mark it stale and let it refetch.
Mechanic 5 — optimistic updates (the advanced move)
Update the UI before the server confirms; roll back if it fails. The pattern — snapshot → optimistic update → rollback on error → reconcile — is worth memorizing cold.
useMutation({
mutationFn: toggleLike,
onMutate: async (postId) => {
await qc.cancelQueries({ queryKey: ['post', postId] }); // stop in-flight refetch
const previous = qc.getQueryData(['post', postId]); // snapshot for rollback
qc.setQueryData(['post', postId], (old) => ({ ...old, liked: !old.liked })); // instant UI
return { previous }; // context → onError
},
onError: (_err, postId, ctx) => qc.setQueryData(['post', postId], ctx.previous), // revert
onSettled: (_d, _e, postId) => qc.invalidateQueries({ queryKey: ['post', postId] }), // reconcile w/ truth
});
When to use it — and when NOT to
The whole rule: use TanStack Query when the data comes from a server you don't control.
| ✅ Use it for | ❌ Don't use it for |
| Fetching over REST/GraphQL/any async source | Pure client/UI state — modal open, theme, form input, wizard step → useState/Zustand |
| Data others can change (so yours goes stale) | Real-time streams — WebSocket push, live cursors → use a socket (can bridge via setQueryData, but not its job) |
| Caching, background refresh, retries, pagination, infinite scroll | Local-first / offline-CRDT as source of truth |
| Read-heavy screens — dashboards, feeds, lists, detail pages | Anything that never leaves the client |
The clean lineServer state → TanStack Query. Client state → useState/Zustand/Redux. Most apps need both, side by side. That's the deep version of the Q1 framing.
What kinds of projects use it
| Project type | Why it fits |
| Dashboards / admin panels | Many read endpoints, tables, filters, pagination — its sweet spot; caching + background refresh shine. |
| E-commerce | Product lists, detail, cart, inventory. Optimistic add-to-cart, invalidation on checkout. |
| Social feeds | useInfiniteQuery for infinite scroll, optimistic likes/comments, refetch on focus. |
| SaaS / CRUD apps | Read records, edit, mutate, invalidate — the bread-and-butter case. |
| Any React SPA on a REST/GraphQL API | The default choice for client-side data fetching in React today. |
Next.js boundary — senior signalIn the Next.js App Router, server components + fetch with Next's caching cover much of what you'd use TanStack Query for on the server. TanStack Query stays king for client-side interactivity — mutations, optimistic UI, infinite scroll, refetch-on-focus. Knowing that boundary is the senior signal.
Worked scenario — stock-trading dashboard
The "do you understand the boundary?" questionPrices update via
WebSocket every 200ms; account balance & order history come from a
REST API. What goes where?
- Account balance, order history → TanStack Query. Request/response REST data that goes stale and benefits from caching, refetch-on-focus, and invalidation after a trade (optimistic order placement + reconcile).
- Live prices → NOT a query. A 200ms push stream is the socket's job; querying can't keep up and would hammer the server. Hold prices in a socket-fed store (Zustand) or, if you want them unified, write socket ticks into the query cache via
setQueryData — but the socket, not useQuery, is the source.
The lesson:
request/response → TanStack Query; continuous push → socket.
Say it out loud"TanStack Query is an async cache for server state, not a state manager. It solves the problems hand-rolled fetching misses — shared cache, dedup, race cancellation, background refresh, retries, stale-while-revalidate — keyed by a query key that is the cache identity. I tune freshness with staleTime and memory with gcTime, write through mutations plus invalidateQueries, and do optimistic updates with snapshot-rollback-reconcile. Rule of thumb: request/response server data goes in it; pure UI state and real-time socket streams don't."
2 memo vs useMemo vs useCallback — when, why, and the caveats
All three cache something to avoid wasted work. The confusion is that they cache different things. Lock this table in:
| API | Caches | Signature | Purpose |
React.memo | a component's render output | HOC wrapping a component | Skip re-render if props are shallow-equal |
useMemo | a computed value | useMemo(() => compute(), deps) | Don't recompute expensive value every render |
useCallback | a function reference | useCallback(fn, deps) | Keep a stable function identity across renders |
The mental model that ties them together
React re-renders a component when its parent renders. React.memo says "skip me if my props didn't change." But it compares props with shallow equality (Object.is) — and in JS, {} !== {} and (() => {}) !== (() => {}). So every render, a parent creates new object and function props, which breaks React.memo's comparison. useMemo and useCallback exist to keep those references stable so React.memo can do its job. They're a team.
The problem — feel it first
function Parent() {
const [count, setCount] = useState(0);
// ❌ New array + new function created EVERY render
const rows = data.filter(r => r.active);
const handleClick = (id) => console.log(id);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ExpensiveList rows={rows} onClick={handleClick} /> // re-renders on every count++
</>
);
}
Every time count changes, rows and handleClick are recreated, so <ExpensiveList> re-renders even though its data didn't change.
The fix — all three together
const ExpensiveList = React.memo(function ExpensiveList({ rows, onClick }) {
/* heavy render */
});
function Parent() {
const [count, setCount] = useState(0);
const rows = useMemo(() => data.filter(r => r.active), [data]); // stable value
const handleClick = useCallback((id) => console.log(id), []); // stable reference
// Now ExpensiveList only re-renders if rows/onClick actually change.
}
Why useCallback is useless on its ownA stable function reference does nothing unless something downstream checks reference equality — i.e. a React.memo child, or a useEffect/useMemo dependency array. Wrapping every handler in useCallback "just in case" only adds overhead (React still allocates the closure + stores it + compares deps). The reference being stable has to matter to something.
Caveats that get asked
- Memoization isn't free. React stores the cached value/fn and compares dependencies every render. For cheap computations the bookkeeping costs more than just recomputing.
- React.memo only does shallow comparison. Pass a nested object that's structurally equal but a new reference → it still re-renders. (You can pass a custom comparator as the 2nd arg, but deep-comparing is usually worse than the re-render.)
- Stale closures with empty deps.
useCallback(fn, []) captures the values from the first render forever. If fn reads count, it'll see the initial count. Fix the deps array or use the functional updater setCount(c => c + 1).
- It's a perf tool, not correctness. Never rely on
useMemo to "run only once" for side effects — that's useEffect's job. React may discard memoized values under memory pressure.
Currency flag — the React CompilerThe React Compiler (React 19) auto-memoizes components, values, and functions at build time. The endgame is that you write the naive version and the compiler inserts the memoization. Manual useMemo/useCallback/React.memo are becoming an escape hatch rather than a daily tool. Say this — it shows you're current and that you understand these were always a workaround for a compiler React didn't yet have.
Say it out loud"React.memo memoizes a component, useMemo a value, useCallback a function reference. The key insight: memo compares props by reference, and JS recreates objects and functions every render — so useMemo and useCallback exist to keep those props stable enough for memo to skip. None of them are free, and useCallback is pointless unless a memoized child or a dependency array actually checks that reference. With the React Compiler, most of this becomes automatic."
3 Preventing unnecessary re-renders in large apps
First, the mental model interviewers want: a component re-renders when (1) its own state changes, (2) its parent re-renders, (3) a context it consumes changes, or (4) — if memoized — its props change. The non-obvious one: a child re-renders whenever its parent does even if props are identical, unless wrapped in React.memo. So "props didn't change" does not stop a re-render on its own — memoization is what makes props the deciding factor. Get that right and the rest follows.
Always diagnose before optimizing
React DevTools Profiler → record an interaction → it highlights what rendered and "why did this render?" Optimizing blind is how you add 40 useMemos that slow the app down. Measure first.
Technique 1 — Composition (the most underrated)
Pass expensive subtrees as children. A component's children prop is created by the parent above it, so when state changes in the middle component, children keeps its reference and doesn't re-render.
// ❌ ExpensiveTree re-renders every time `open` toggles
function Layout() {
const [open, setOpen] = useState(false);
return <div><Toggle open={open} onToggle={setOpen} /><ExpensiveTree /></div>;
}
// ✅ Move state into a wrapper; pass ExpensiveTree as children
function Layout({ children }) {
const [open, setOpen] = useState(false);
return <div><Toggle open={open} onToggle={setOpen} />{children}</div>;
}
// <Layout><ExpensiveTree /></Layout> — ExpensiveTree no longer re-renders on toggle
Technique 2 — State colocation
Move state down to the smallest component that needs it. If a search box's text lives at the app root, every keystroke re-renders the whole app. Push it into the search component and only that subtree updates.
Technique 3 — React.memo + stable props
(See Q2.) Memoize pure, expensive children; keep their object/function props stable with useMemo/useCallback.
Technique 4 — Context splitting / selectors
Split a fast-changing value out of a shared context, or use a selector store (Zustand) so components subscribe to slices, not the whole object. (See Q1's Context caveat.)
Technique 5 — List virtualization
Rendering 10,000 rows mounts 10,000 DOM nodes. Virtualization (TanStack Virtual, react-window) renders only the ~20 rows in the viewport and recycles them as you scroll. DOM size becomes constant regardless of data size.
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // row height px
});
// render only rowVirtualizer.getVirtualItems() — ~20 nodes, not 10,000
Architecture / LLD lensRe-render cost scales with tree depth × frequency. The architectural fixes (composition, colocation, splitting state by update frequency) attack the problem structurally — they reduce how much of the tree is even eligible to re-render. The tactical fixes (memo, useMemo) just make eligible renders cheaper. Senior signal: reach for structure first, memoization second. A well-shaped tree needs far less memoization.
CaveatNever use array index as key for dynamic lists. On insert/reorder/delete, React mismatches elements to DOM nodes — causing wrong state, lost input focus, and visual bugs. Use a stable unique id.
Say it out loud"I profile first with the React DevTools Profiler. Then I attack it structurally before reaching for memo — composition to keep subtrees out of the render path, colocating state so updates stay local, and splitting context by update frequency. Memo and useMemo are the tactical layer on top, and virtualization for big lists so DOM size stays constant."
4 Suspense for data fetching
Suspense is a declarative boundary for "not ready yet." A component can suspend — signal to React "I'm waiting on something" — and the nearest <Suspense> ancestor shows its fallback until the component is ready. Mechanically, a suspending component throws a promise; React catches it, shows the fallback, and retries the render when the promise resolves.
<Suspense fallback={<Spinner />}>
<Profile userId={id} /> // suspends while its data loads
</Suspense>
Why it's better than isLoading flags
The problem it solvesWithout Suspense, every data-fetching component carries its own if (isLoading) return <Spinner/>. Ten components = ten scattered spinners, ten loading states to coordinate, and "waterfalls" where each fetch waits for the one above. Suspense hoists loading state out of the components and into the tree — one boundary describes the loading UI for everything beneath it, and you can place boundaries to control granularity.
How you actually wire it (you don't throw promises by hand)
- TanStack Query:
useSuspenseQuery — the hook suspends instead of returning isLoading.
- Next.js App Router / RSC:
async server components + a loading.tsx file is an automatic Suspense boundary.
- React 19
use() hook: unwraps a promise directly and suspends.
// React 19 — the use() hook
function Profile({ userPromise }) {
const user = use(userPromise); // suspends until resolved
return <h1>{user.name}</h1>;
}
// Next.js App Router — async server component
async function Page() {
const data = await getData(); // loading.tsx shows while this awaits
return <Dashboard data={data} />;
}
Two things to pair it with
- Error Boundaries — Suspense handles the pending state; an Error Boundary handles the rejected state. They're complementary; you wrap both.
- Streaming SSR — on the server, React streams HTML in chunks: it sends the shell with fallbacks immediately, then streams each section's HTML as its data resolves. This is what makes RSC feel fast.
Architecture / LLD lensSuspense inverts control: instead of each component imperatively reporting "I'm loading," it declaratively suspends, and a parent decides how to present that. This is the same separation-of-concerns win as Error Boundaries (errors bubble to a handler) — loading and error presentation become cross-cutting concerns owned by boundary components, not duplicated in every leaf. It also enables concurrent rendering: React can prepare a suspended tree off-screen and swap it in only when ready, avoiding spinner flicker.
Caveat 1 — waterfallsSuspense doesn't fetch anything — it only reacts to a thrown promise. Naive use creates request waterfalls (child suspends, then its child suspends sequentially). Avoid by hoisting fetches / prefetching in parallel above the boundary.
Caveat 2 — the promise must be stableYou cannot do use(fetch(url)) directly in render. Every render creates a new promise → React suspends, re-renders, creates another promise → infinite loop. The promise must be created outside render or cached (e.g. by TanStack Query, a cache, or passed down as a prop created higher up). This is the #1 mistake people make hand-rolling Suspense — and why you almost always use a library that caches the promise for you.
Say it out loud"Suspense is a declarative boundary for the not-ready state — a component throws a promise, React shows the nearest fallback and retries when it resolves. It moves loading state out of scattered isLoading checks and into the tree, pairs with Error Boundaries for the failure case, and is what powers streaming SSR and RSC. I wire it through useSuspenseQuery or Next's async components rather than throwing promises by hand."
5 Structuring a large-scale component library
The goal of a component library is consistency and accessibility by default across many apps and teams — change a token once, every product updates. Here's the architecture I'd defend:
1. Layer the components (atomic-ish)
- Primitives: Button, Input, Text, Box — the alphabet.
- Composites: SearchBar, FormField, Card — primitives combined.
- Patterns: DataTable, Modal, Wizard — full interaction units.
2. Headless behavior + token-driven styling (the key architectural call)
Build on headless primitives — Radix UI or React Aria — which give you correct behavior and accessibility (focus traps, ARIA roles, keyboard nav, screen-reader support) with zero styling. You layer your own styles on top via design tokens.
Architecture / LLD lens — why headlessThis is separation of behavior from presentation. Accessibility and interaction logic (a focus trap, roving tabindex, ARIA wiring) are hard, identical across companies, and easy to get subtly wrong. Outsourcing them to a battle-tested headless lib means every component is accessible by default and you can't ship a Dropdown that traps keyboard users. Your team's value-add — the visual brand — stays fully under your control. It's the Strategy pattern: behavior is fixed and correct; styling is the pluggable strategy.
3. Design tokens as the single source of truth
/* tokens.css — one place defines the system */
:root {
--color-primary: #1f6feb;
--space-md: 12px;
--radius-md: 8px;
--font-body: 'Inter', sans-serif;
}
/* Components consume tokens, never hard-coded values.
Rebrand or dark-mode = swap the token layer, nothing else. */
4. Repo & tooling
- Monorepo (pnpm workspaces + Turborepo) — library, docs, and demo apps version together; Turborepo caches builds.
- Storybook — develop & document each component in isolation, every state/variant on display.
- Chromatic (or Playwright snapshots) — visual regression testing catches "this PR shifted Button padding by 2px" automatically.
- Changesets — semver versioning + changelogs for releases.
5. API design discipline (LLD of a component)
- Controlled + uncontrolled support (accept
value or defaultValue).
forwardRef so consumers can reach the DOM node.
- Polymorphic
as prop — render a Button as an <a> when it's a link.
- Spread rest props to the root element so consumers can pass
aria-*, data-*, etc.
- TypeScript-first — props are the contract; good types are the docs.
const Button = forwardRef(({ as: Tag = 'button', variant = 'primary', ...rest }, ref) => (
<Tag ref={ref} className={styles[variant]} {...rest} /> // rest = consumer escape hatch
));
Say it out loud"I build on headless primitives like Radix for behavior and accessibility, layer token-driven styling so a rebrand is a one-file change, and ship it from a Turborepo documented in Storybook with Chromatic visual regression and Changesets versioning. The architectural bet is separating behavior from presentation — accessibility comes for free and the brand stays ours."
R Virtual DOM — what it is and why it's fast
The Virtual DOM is a lightweight JS object tree describing what the UI should look like. When state changes, React builds a new tree, diffs it against the previous one (reconciliation), and applies only the minimal set of real DOM changes.
Real-world analogyEditing the real DOM directly is like demolishing and rebuilding a house room by room every time you change the paint — DOM operations trigger expensive layout/reflow/repaint. The Virtual DOM is the architect's blueprint: you scribble changes on paper (cheap JS objects), compare old vs new blueprint, then send the builders in once to change only what actually differs.
The honest senior caveatThe Virtual DOM is not "fast" in absolute terms — diffing has a cost. Hand-tuned vanilla DOM updates are faster. What the VDOM buys is fast enough while letting you write declarative UI = f(state) code instead of manual imperative DOM surgery. Newer frameworks (Svelte, Solid) skip the VDOM entirely by compiling to direct DOM updates — proof the VDOM is a trade-off, not a law.
Say it out loud"The VDOM is an in-memory tree React diffs to compute the minimal real-DOM mutation. Its real value isn't raw speed — it's letting me write declarative UI-as-a-function-of-state while React batches and minimizes the expensive DOM work."
R useState vs useReducer (and lazy initial state)
Both hold local state. The split is about how complex the transitions are.
useState | useReducer |
Independent values, simple setX(newValue) | Next state depends on previous state + an "action type" |
| A toggle, an input, a counter | Multi-field forms, state machines, "several values change together" |
| Logic lives inline in handlers | Logic centralized in a pure reducer(state, action) — testable, no mocks |
// useReducer: transitions are explicit, centralized, testable
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'reset': return { count: 0 };
default: throw new Error('unknown action');
}
}
const [state, dispatch] = useReducer(reducer, { count: 0 });
// dispatch({ type: 'increment' }) — same Command pattern as Redux, but local
And lazy initial state (the gist's "useState accepting a function")Passing a function to useState/useReducer makes the expensive init run once on mount, not on every render. useState(expensiveCalc()) calls expensiveCalc every render and throws the result away; useState(() => expensiveCalc()) calls it once. Same reason it matters for reading localStorage on init.
Say it out loud"useState for independent simple values; useReducer when the next state depends on the previous one or several values move together — it centralizes transitions in a pure, testable reducer. It's local Redux. And I pass a function for initial state so expensive setup runs once, not every render."
R useRef & list virtualization
useRef — the two jobs
A ref is a mutable box whose .current persists across renders but does NOT trigger a re-render when it changes. Two uses:
- Access a DOM node — focus an input, measure size, play a video:
<input ref={inputRef}/> then inputRef.current.focus().
- Hold a mutable value that shouldn't cause renders — a timer/interval ID, the previous value of a prop, a "has this mounted yet" flag.
The distinction that gets testedState change → re-render. Ref change → no re-render. So if a value should appear on screen, it's state; if it's bookkeeping the render doesn't need (interval IDs, previous values), it's a ref. Mutating a ref during render is a bug — do it in effects/handlers.
Virtualization (windowing)
Rendering 10,000 rows means 10,000 DOM nodes → the browser chokes. Virtualization renders only the ~20 rows visible in the viewport (plus a small buffer) and recycles them as you scroll, using padding to fake the full scroll height.
Real-world analogyYou don't print a 10,000-page book to read page 50 — you turn to the pages you can see. react-window / @tanstack/virtual do exactly that: the DOM only ever holds what's on screen, so a million-row list scrolls at 60fps with constant memory.
import { FixedSizeList } from 'react-window';
<FixedSizeList height={600} itemCount={10000} itemSize={40} width={'100%'}>
{({ index, style }) => <div style={style}>Row {index}</div>} /* only ~15 mounted at once */
</FixedSizeList>
R Meta-frameworks, React without one & when to build an SPA
What's a "meta-framework"?
React is just a UI library — it renders components and nothing else. A meta-framework (Next.js, Remix/React Router, TanStack Start) wraps React with the things a real app needs: routing, data fetching, SSR/SSG, bundling, code splitting, API layer. "Meta" = a framework built on top of a library.
React without a meta-framework
Totally valid: Vite + React + a router (React Router / TanStack Router) gives you a pure client-side SPA. You choose this when you don't need SSR/SEO — internal tools, dashboards behind a login, admin panels.
When to build an SPA (vs MPA/SSR)
| Build an SPA when… | Avoid an SPA (use SSR/MPA) when… |
| App-like, highly interactive (editor, dashboard, Figma-style) | Content site that lives or dies on SEO (blog, store, marketing) |
| Behind a login — SEO irrelevant | First-paint speed on slow devices is critical |
| Lots of client state & transitions between views | Mostly static, read-heavy pages |
| You can afford a heavier initial JS download once | You need shareable, crawlable URLs with rich previews |
Say it out loud"React is a UI library; a meta-framework like Next adds routing, data, SSR and bundling on top. For an SEO-less internal tool I'll happily ship a Vite SPA with a router. I reach for SSR/a meta-framework when SEO, first-paint, or social previews matter — content sites — and keep SPAs for app-like, behind-login experiences."
R WAI-ARIA — what it is and the #1 rule
WAI-ARIA (Accessible Rich Internet Applications) is a spec of attributes — role, aria-label, aria-expanded, aria-live — that describe the purpose and state of custom UI to assistive tech (screen readers), when native HTML can't.
The #1 rule (and the favorite gotcha)"No ARIA is better than bad ARIA." First rule of ARIA: don't use it if a native element exists. A <button> is keyboard-focusable, clickable with Enter/Space, and announced as a button — for free. A <div role="button"> gives you the label but none of the behavior; you must re-add tabindex, keydown handlers, and focus styles by hand, and people usually forget. Use semantic HTML first; reach for ARIA only for genuinely custom widgets (tabs, comboboxes, live regions).
// Native — accessible by default, zero ARIA needed
<button onClick={open}>Menu</button>
// Custom widget that HAS no native element — ARIA describes state to screen readers
<div role="tablist">
<button role="tab" aria-selected={active} aria-controls="panel-1">Tab 1</button>
</div>
<div role="region" aria-live="polite">{statusMessage}</div> // announces updates
Say it out loud"ARIA is attributes that expose role and state to assistive tech. The first rule is don't use it when native HTML works — a real <button> beats a div role=button because it brings keyboard and focus behavior free. ARIA is for custom widgets HTML can't express: tabs, comboboxes, live regions."
🟨 JavaScript Core — the language questions seniors get
These are the "do you actually know the language, not just React" questions. Each one has a concrete reason it exists — interviewers use them to separate people who memorized syntax from people who understand the engine.
JS Map vs Object — when and why
Both store key→value. The instinct is "they're the same." They are not — they're optimized for different jobs.
Real-world analogyAn Object is a struct / record — a fixed shape you designed: { name, email, age }. A Map is a dictionary / hash table — a bag of arbitrary entries you add and remove at runtime: "userId → cart", "socketId → connection". If you're describing one known thing, use an Object. If you're indexing many unknown things, use a Map.
| Dimension | Object | Map |
| Key types | strings & symbols only (numbers get coerced to strings) | any value — objects, functions, DOM nodes, numbers |
| Order | insertion order, but integer-like keys jump to the front | guaranteed insertion order, always |
| Size | Object.keys(o).length — O(n) | map.size — O(1) |
| Iteration | need Object.entries/keys | directly iterable: for (const [k,v] of map) |
| Default keys | inherits __proto__, toString… → prototype-pollution risk | no inherited keys — clean slate |
| Perf for frequent add/delete | slower; engines de-opt objects used as dictionaries | optimized for it |
// The killer difference: Map keys can be ANY value
const el = document.querySelector('#btn');
const meta = new Map();
meta.set(el, { clicks: 0 }); // DOM node as a key — impossible with Object
meta.get(el).clicks++;
// Object footgun: numeric keys become strings, and inherited keys exist
const o = {};
o[1] = 'a'; console.log(Object.keys(o)); // ['1'] — number became a string
console.log(o['toString']); // ƒ toString() — a key you never set!
Architecture / LLD lensAny time you build a cache, lookup index, or registry (request dedup, memoization keyed on arguments, "which socket belongs to which user") — reach for Map. Using a plain object there invites the prototype-pollution security bug (an attacker sends a key named __proto__) and the de-opt. Use objects for known-shape records you serialize to JSON.
Say it out loud"Object is a record with a known shape; Map is a hash table for arbitrary, runtime keys. Map gives any-type keys, guaranteed order, O(1) size, no prototype baggage, and it's faster for heavy add/delete — so caches and indexes are Maps, DTOs are objects."
JS Map vs WeakMap — the memory-leak question
A WeakMap is a Map whose keys are held weakly: if the only reference left to a key object is the WeakMap entry, the garbage collector is free to delete it.
Real-world analogyA Map is a coat-check that keeps your coat forever, even after you've left the building — it won't let go. A WeakMap is a coat-check that throws out your coat the moment you leave: the entry only survives as long as something else still cares about the key.
// Use case: attach private data to an object WITHOUT leaking when it dies
const privateData = new WeakMap();
class Widget {
constructor(node) {
privateData.set(this, { node, listeners: [] });
}
}
// When a Widget is no longer referenced, its WeakMap entry is GC'd automatically.
// With a normal Map, the entry would pin the Widget in memory forever → leak.
| Map | WeakMap |
| Keys | any value | objects only (not primitives) |
| References | strong — pins keys in memory | weak — GC can reclaim keys |
Iterable / .size | yes | no — can't enumerate (GC timing is nondeterministic) |
| Use for | caches you control & iterate | metadata tied to an object's lifetime |
The trapYou can't iterate or get .size of a WeakMap — because entries vanish whenever the GC runs, so any count would be a lie. If you need to list entries, you need a Map (and you own the leak risk).
Say it out loud"WeakMap holds keys weakly, so attaching per-object metadata doesn't prevent that object from being garbage-collected. It's the leak-proof way to associate private data with DOM nodes or class instances — at the cost of not being enumerable."
JS === vs Object.is() (and why == is banned)
Three equality checks, increasingly precise:
== (loose) — coerces types before comparing. 0 == '', 1 == true, null == undefined are all true. Source of bugs → never use it (except the one idiom x == null to catch both null & undefined).
=== (strict) — no coercion; same type AND same value. The default you should reach for.
Object.is() — like === with two fixes.
// The two cases where === lies and Object.is tells the truth:
NaN === NaN; // false — but NaN really IS NaN
Object.is(NaN, NaN); // true ✅
-0 === +0; // true — but they're distinguishable
Object.is(-0, +0); // false ✅ (1/-0 = -Infinity, 1/+0 = +Infinity)
Where this actually mattersReact's bailout (useState/useMemo dependency comparison) uses Object.is semantics, not ===. That's why setting state to NaN twice doesn't loop forever, and why -0 vs 0 edge cases behave predictably. Knowing React uses Object.is is a strong signal you understand the reconciler.
Say it out loud"=== is strict equality with no coercion. Object.is is the same except it treats NaN as equal to itself and distinguishes +0 from -0 — which is exactly the comparison React uses internally for bailouts."
JS Object.freeze() — and why it's shallow
Object.freeze(obj) makes an object immutable: no adding, deleting, or reassigning properties. In strict mode, attempts throw; otherwise they fail silently.
const config = Object.freeze({ api: '/v1', retries: 3 });
config.retries = 5; // silently ignored (throws in strict mode)
Object.isFrozen(config); // true
// ⚠️ SHALLOW — nested objects are still mutable!
const state = Object.freeze({ user: { name: 'Sonali' } });
state.user.name = 'Changed'; // ✅ works — freeze didn't reach inside
// Deep freeze = recurse
function deepFreeze(o) {
Object.keys(o).forEach(k => {
if (typeof o[k] === 'object' && o[k] !== null) deepFreeze(o[k]);
});
return Object.freeze(o);
}
Architecture / LLD lensUse it for true constants — config objects, enums, action-type maps — so a teammate can't accidentally mutate shared state at runtime. It's a runtime guard; TypeScript's as const / readonly is the compile-time version. The shallow caveat is the whole question: interviewers want to hear "freeze is one level deep; nested objects need a recursive deepFreeze."
JS async/await vs .then() chains
Same Promises underneath — async/await is syntax sugar over .then(). The difference is readability and control flow.
// .then chain — pyramid grows, error handling is a separate .catch
fetchUser(id)
.then(user => fetchPosts(user.id))
.then(posts => render(posts))
.catch(err => showError(err));
// async/await — reads like sync code, try/catch is normal
async function load(id) {
try {
const user = await fetchUser(id);
const posts = await fetchPosts(user.id);
render(posts);
} catch (err) { showError(err); }
}
The senior trap: accidental serializationawait in a loop or back-to-back makes independent requests run one after another. If they don't depend on each other, run them in parallel with Promise.all:
// ❌ 3 sequential round-trips (~3× latency)
const a = await getA(); const b = await getB(); const c = await getC();
// ✅ fired together, await once (~1× latency)
const [a, b, c] = await Promise.all([getA(), getB(), getC()]);
When .then still winsFor a quick one-off transform or fire-and-forget side effect, .then() is tighter. And .then() composes nicely in points-free pipelines. But for anything with multiple dependent steps or branching, async/await + try/catch is clearer and easier to debug (real stack traces).
Say it out loud"They're the same Promises — await is sugar over .then. I default to async/await for readable, debuggable control flow, but I watch for accidental serialization: independent awaits should be Promise.all'd so they run in parallel."
JS Generators — functions that pause
A generator (function*) is a function that can pause at yield and resume later, producing a sequence of values lazily — one at a time, on demand, instead of all at once.
Real-world analogyA normal function is a vending machine that dumps the entire stock when you press the button. A generator is a Pez dispenser: each .next() pops one item; it remembers where it left off and produces the next only when asked. That "remember + resume" is the whole point.
function* idGenerator() {
let id = 1;
while (true) yield id++; // infinite, but lazy — only runs when asked
}
const gen = idGenerator();
gen.next().value; // 1
gen.next().value; // 2 — function resumed where it paused
// Lazy sequence: only computes what you consume
function* take(iter, n) {
for (const x of iter) { if (n-- <= 0) return; yield x; }
}
Where they show up in real codeYou rarely write generators by hand, but they power things you use daily: Redux-Saga models async flows as yielded "effects" (pausable, testable, cancellable), and async/await itself is conceptually a generator that yields Promises. They're also the cleanest way to express infinite or huge sequences (pagination cursors, ID streams) without materializing them in memory. Interview value: they prove you understand lazy evaluation and that functions don't have to run start-to-finish.
Say it out loud"A generator is a pausable function — yield suspends it and .next() resumes it, producing values lazily. That powers lazy/infinite sequences and libraries like Redux-Saga; conceptually async/await is a generator yielding promises."
JS Hoisting & the Temporal Dead Zone
Before any code runs, the engine scans the scope and registers declarations. That's hoisting. How they're registered differs by keyword — and that's the whole question.
| Declaration | Hoisted? | Usable before its line? |
var | yes, initialized to undefined | yes — but you get undefined (silent bug) |
let / const | yes, but uninitialized | no — ReferenceError (Temporal Dead Zone) |
function declaration | yes, fully | yes — callable above its definition |
class | yes, but in the TDZ | no — ReferenceError |
console.log(a); // undefined — var hoisted & initialized
var a = 1;
console.log(b); // ❌ ReferenceError — let is in the TDZ until its line
let b = 1;
greet(); // ✅ works — function declarations are fully hoisted
function greet() { return 'hi'; }
The point of the TDZlet/const are hoisted too — but accessing them before declaration throws instead of returning undefined. That "dead zone" exists deliberately: it turns a silent var bug into a loud error. This is why modern code uses const by default, let when reassigning, and var essentially never.
🧠 Performance & Optimization
6 Handling slow API responses or large payloads
Split the problem into two questions: (a) how do I move less data / fewer times? and (b) how do I make waiting feel instant? Senior answers cover both — actual perf and perceived perf.
Reduce the data on the wire
- Pagination / cursor paging: never fetch 10k rows. Cursor-based (
?after=id) beats offset paging — offset gets slow and drifts when rows are inserted mid-scroll.
- Fetch only needed fields: GraphQL, or REST sparse fieldsets (
?fields=id,name). Stops over-fetching.
- Compression: gzip/brotli at the server/CDN — often 70%+ smaller for JSON.
- BFF (Backend-for-Frontend): a thin server layer that aggregates and trims responses for the UI, so the client makes one shaped call instead of five raw ones.
Reduce the number of requests
- Dedup in-flight requests — TanStack Query collapses identical concurrent queries into one.
- Debounce/throttle user-driven requests (search-as-you-type).
- Cancel stale requests with
AbortController so a fast typist doesn't race responses.
function useSearch(term) {
return useEffect(() => {
const ctrl = new AbortController();
fetch(`/search?q=${term}`, { signal: ctrl.signal })
.then(r => r.json())
.catch(e => { if (e.name !== 'AbortError') throw e; });
return () => ctrl.abort(); // cancel previous on new keystroke / unmount
}, [term]);
}
Make waiting feel instant (perceived performance)
- Skeleton screens over spinners — they preview layout, feel faster, and prevent layout shift.
- Optimistic updates — update the UI immediately, roll back if the server rejects.
- stale-while-revalidate — show cached data instantly, refetch in the background, swap when fresh.
- Streaming — render the page shell immediately, stream slow sections in (Suspense + RSC).
// Optimistic update with TanStack Query
useMutation({
mutationFn: toggleLike,
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ['post', id] });
const prev = qc.getQueryData(['post', id]);
qc.setQueryData(['post', id], p => ({ ...p, liked: !p.liked })); // instant UI
return { prev }; // rollback context
},
onError: (_e, id, ctx) => qc.setQueryData(['post', id], ctx.prev), // revert
});
Architecture / LLD lensThe biggest wins are usually not on the client — they're moving work to the right layer: aggregate at a BFF, cache at a CDN/edge, paginate at the DB. Client tricks (debounce, virtualize, optimistic UI) shave the last mile. Name the layer where each fix lives — that's the system-thinking signal.
Say it out loud"I attack it on two axes: reduce data — pagination, field selection, compression, a BFF to shape responses — and reduce perceived wait — skeletons, optimistic updates, stale-while-revalidate, streaming. And I dedupe and cancel requests with AbortController so a fast typist doesn't race responses."
7 Bundle optimization — tree shaking, lazy loading, dynamic imports
The framing that makes these click: tree shaking shrinks the bundle at build time; code splitting defers parts of it to runtime. Different problems. One removes code you never use; the other delays code you don't need yet.
Tree shaking — remove dead code
The bundler statically analyzes import/export and drops exports nobody uses. Requirements:
- ES modules, not CommonJS. Tree shaking relies on static
import/export — require() is dynamic and can't be analyzed.
- Named imports.
import { debounce } from 'lodash-es' shakes; import _ from 'lodash' pulls the whole library.
"sideEffects": false in package.json tells the bundler "importing this file does nothing but export" so it can safely drop unused ones. (List exceptions like CSS imports.)
Code splitting + lazy loading — defer to runtime
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./routes/Dashboard')); // own chunk, loaded on demand
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
Dynamic imports — load heavy things only when used
// Don't ship a 300kb charting lib in the main bundle —
// load it only when the user opens the analytics panel.
async function openCharts() {
const { renderChart } = await import('./heavy-charts'); // returns a promise
renderChart();
}
Other levers
- Analyze:
vite-bundle-visualizer / webpack-bundle-analyzer — see what's actually big.
- Swap heavy deps: moment.js (~70kb) → day.js/date-fns; lodash → lodash-es or native.
- Dedupe React — two copies of React is a classic bundle bloat + runtime bug.
- Compression + hashed filenames for long-term caching (see Q9).
Architecture / LLD lensThe architectural decision is your split boundaries. Route-level splitting is the highest-leverage line to draw — it maps chunks to navigation, so users download only the page they visit. Then split heavy, rarely-used widgets (editors, charts, maps). Splitting too finely creates a waterfall of tiny requests; too coarsely defeats the purpose. The boundary is a design choice, not an afterthought.
Say it out loud"Tree shaking removes dead code at build time — it needs ES modules, named imports, and a sideEffects flag. Code splitting and dynamic imports defer code to runtime — I split at route boundaries first, then lazy-load heavy widgets. I verify with a bundle analyzer and swap bloated deps like moment for dayjs."
8 Web Vitals — measuring and improving LCP, INP, CLS
FID is dead — lead with thisGoogle replaced FID with INP (Interaction to Next Paint) as a Core Web Vital in March 2024. Saying "FID" in 2026 dates you. Answer with INP; if they say FID, correct it gracefully (one-liner below).
The three Core Web Vitals
| Metric | Measures | Good | Main fixes |
LCP Largest Contentful Paint | Loading — when the biggest element (hero image/heading) paints | ≤ 2.5s | Optimize/preload hero image, fetchpriority="high", fast TTFB/CDN, cut render-blocking CSS/JS, SSR |
INP Interaction to Next Paint | Responsiveness — lag from any interaction to the next paint, across the whole session | ≤ 200ms | Break up long tasks, useTransition for non-urgent updates, web workers for heavy compute, debounce, yield to main thread |
CLS Cumulative Layout Shift | Visual stability — how much content jumps around | ≤ 0.1 | Set width/height on images & embeds, reserve space for ads/dynamic content, avoid inserting above existing content, use font-display: optional/swap |
Why INP replaced FID (the depth)
The reasoningFID measured only the input delay of the very first interaction — and only the delay before the handler ran, not how long the UI took to update. It was easy to game and didn't reflect real frustration. INP measures every interaction all session long and the full time until the next paint — so a janky dropdown on click #20 now counts. It captures responsiveness as users actually experience it.
How to measure — lab vs field
- Lab (synthetic): Lighthouse, PageSpeed Insights, Chrome DevTools — reproducible, but one machine/network. INP isn't fully measurable in lab (needs real interactions).
- Field (RUM — real user monitoring): the
web-vitals npm library reports real users' metrics to your analytics; Chrome UX Report (CrUX) is field data Google actually ranks you on.
import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP(sendToAnalytics);
onINP(sendToAnalytics); // real-user field data — what Google ranks on
onCLS(sendToAnalytics);
If they say "FID""FID only measured the delay of the first interaction; INP superseded it in March 2024 because it captures responsiveness across every interaction in the session, not just the first tap." — that one sentence wins the room.
Say it out loud"The three are LCP for load, INP for responsiveness — that replaced FID in 2024 — and CLS for visual stability. I measure in two places: Lighthouse for lab and the web-vitals library feeding RUM for field data, since Google ranks on field. LCP I fix by preloading the hero and cutting render-blocking resources; INP by breaking up long tasks and using useTransition; CLS by reserving space for images and dynamic content."
9 Caching strategy for frontend apps
There's no single cache — there are layers, each catching a different request closer to the user. Name them as a stack; that's the senior framing.
| Layer | Caches what | Mechanism |
| Browser HTTP cache | static assets, API responses | Cache-Control, ETag, max-age |
| CDN / edge | static assets, cacheable API | edge nodes near the user |
| Service Worker | offline shell, assets, data | Workbox cache strategies |
| App data cache | fetched server state | TanStack Query / SWR in-memory |
| Persistent storage | cross-session data | localStorage / IndexedDB |
1. HTTP caching + content hashing (the foundation)
Build tools fingerprint filenames: app.9f2a1c.js. Because the hash changes when content changes, you can cache these forever (Cache-Control: max-age=31536000, immutable) and bust the cache by shipping a new filename. The index.html stays no-cache so it always points at the latest hashed assets.
2. The cache strategies (name them)
- Cache-first: serve from cache, only hit network on miss. For immutable assets (fonts, hashed JS).
- Network-first: try network, fall back to cache. For data that should be fresh but must work offline.
- Stale-while-revalidate: serve cache instantly and refetch in background, update next time. The sweet spot for most data — instant + eventually fresh. This is TanStack Query's default model.
// TanStack Query caching knobs
useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 30_000, // "fresh" 30s → served from cache, no refetch
gcTime: 5 * 60_000, // kept in memory 5min after unused, then garbage-collected
});
Caveat — the hard part is invalidation"There are only two hard things in CS: cache invalidation and naming things." Stale data is the #1 caching bug. That's why content-hashing (immutable URLs) and key-based invalidation (invalidateQueries) win — they make invalidation explicit and deterministic instead of guessing at TTLs.
Architecture / LLD lensEach layer trades freshness for speed/cost, and the right TTL depends on how tolerant the data is to staleness — a stock price (seconds) vs a blog post (days) vs a logo (forever). The architecture is choosing the staleness budget per data type and placing each in the cheapest layer that satisfies it. CDN for "same for everyone," app cache for "per user, short-lived," immutable HTTP cache for "never changes within a build."
Say it out loud"I cache in layers: content-hashed assets cached forever on the CDN and browser, busted by filename; a service worker for offline; and TanStack Query for server state with stale-while-revalidate. The real design work is the invalidation strategy — I lean on immutable hashed URLs and key-based invalidation so freshness is deterministic, not a TTL guess."
10 Identifying and fixing memory leaks in React
A leak is memory the app no longer needs but the garbage collector can't reclaim because something still references it. In React, that "something" is almost always a subscription, timer, or listener that outlived its component.
The four classic causes — and fixes
- Uncleaned timers/subscriptions:
setInterval, event listeners, WebSocket, store subscriptions. → Return a cleanup function from useEffect.
- State update after unmount: async resolves after the component is gone, calls
setState on a dead component. → Abort the request / guard with a flag.
- Closures capturing large objects in long-lived references: a callback stored in a ref, a global subscription, or module-level cache closes over a big structure (or a whole component's props/state) and keeps it alive after it's no longer needed.
- Listeners on
window/document: not removed → the handler (and everything it closes over) stays alive forever.
function LiveClock() {
const [now, setNow] = useState(() => Date.now()); // lazy init
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id); // ✅ cleanup — without this, the timer leaks on unmount
}, []);
return <span>{now}</span>;
}
// Async fetch — cancel to avoid "setState on unmounted component"
useEffect(() => {
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal }).then(setData).catch(() => {});
return () => ctrl.abort(); // ✅ cancel in-flight on unmount
}, [url]);
How to detect (this is what separates juniors)
- Chrome DevTools → Performance Monitor: watch "JS heap size" while using the app. A sawtooth that trends upward and never drops after GC = leak.
- Memory tab → Heap snapshots: take a snapshot, perform the suspect action (open/close a modal 10×), take another, and compare. Filter for "Detached" DOM nodes — DOM removed from the page but still referenced by JS — the smoking gun.
- Allocation timeline: records allocations over time so you can see what's growing.
Architecture / LLD lensThe root cause is almost always a lifecycle mismatch: a resource's lifetime is tied to something longer-lived than the component that created it (a global event bus, window, a module-level singleton). The discipline is "every subscription has a matching unsubscription, co-located in the same effect." useEffect's setup/cleanup pairing is React encoding exactly that contract — acquire on mount, release on unmount. Leaks happen when you acquire and forget to release.
Caveat — StrictModeIn dev, React 18 StrictMode intentionally mounts → unmounts → remounts each component to surface missing cleanups. If your effect breaks under StrictMode, you have a cleanup bug — that's the feature working, not a React bug.
Say it out loud"Most React leaks are missing useEffect cleanups — timers, listeners, subscriptions, or setState after unmount. The fix is the setup/cleanup contract: every acquire has a matching release in the same effect, plus AbortController for fetches. I confirm with heap snapshots in DevTools, comparing before and after an action and hunting for detached DOM nodes that never get collected."
⚙️ Build, CI/CD & Tooling
11 Environment configs for different builds
The core decision is one most people get wrong: build-time config vs runtime config. Get this distinction right and the rest is detail.
Build-time config (the default)
Values are baked into the bundle when you build. Vite exposes only VITE_-prefixed vars via import.meta.env; everything else stays out of the client for safety.
# .env.development
VITE_API_URL=http://localhost:3000
# .env.production
VITE_API_URL=https://api.myapp.com
const apiUrl = import.meta.env.VITE_API_URL; // inlined at build time
Vite auto-loads .env.[mode] based on the --mode flag. CI runs a separate build per environment.
Runtime config (for containers / one-build-everywhere)
Why this mattersBuild-time config means one build artifact per environment — you rebuild for staging and again for prod. That breaks the Docker/Kubernetes ideal of "build one immutable image, promote it through environments." The fix: runtime config — the app fetches /config.json on startup, or the container injects values into window.__ENV__ at boot. One image, configured per environment at deploy time.
// public/config.json — swapped per environment at deploy, not build
{ "apiUrl": "https://api.myapp.com" }
// app bootstraps from it
const cfg = await fetch('/config.json').then(r => r.json());
Caveat — secretsAnything in the frontend bundle is public. View-source, network tab, decompile — it's all visible. API keys, DB creds, signing secrets never go in client env vars (the VITE_ prefix is a safety guard, not encryption). Secrets live server-side; the client gets only public config and short-lived tokens.
Architecture / LLD lensThis is the 12-Factor App "config in the environment" principle. The trade-off: build-time is simpler and faster but couples artifact to environment; runtime decouples them (immutable promotable images) at the cost of one startup fetch. Pick build-time for static-hosted SPAs (Netlify/Vercel), runtime for containerized deploys where the same image flows dev → staging → prod.
Say it out loud"Per-environment .env files for build-time config, with Vite's VITE_ prefix gating what reaches the client. For containerized deploys I switch to runtime config — fetch a config.json or read injected window env — so one immutable image promotes through all environments. And secrets never touch the bundle; anything shipped to the client is public by definition."
12 Workflow with Webpack / Vite / Turbopack
The thing to understand isn't the tools — it's the architectural shift they represent: from "bundle everything before serving" to "serve native ES modules and only bundle for production."
| Tool | Dev model | Prod build | Speed |
| Webpack | Bundles the whole app before the dev server can serve it | Webpack | Slow dev startup; scales poorly with app size |
| Vite | Serves native ESM, transforms on-demand via esbuild (Go) — no pre-bundle | Rollup | Near-instant startup & HMR, flat regardless of app size |
| Turbopack | Rust, incremental, function-level caching; bundles in dev but lazily | Turbopack | Very fast; default in Next.js 16 for both dev & build |
Why Vite's dev server is so much faster (the depth)
The reasoningWebpack builds a dependency graph and bundles everything before serving the first page — so dev startup time grows with your app. Vite exploits that browsers now support native ES modules: it serves your source files directly as ESM and only transforms a file when the browser requests it. Startup is near-instant and stays flat no matter how big the app gets. It uses esbuild (written in Go, 10–100× faster than JS-based tooling) to pre-bundle dependencies. Production still bundles — via Rollup — because shipping hundreds of unbundled module requests to real users would be slow.
Caveat — why not esbuild for prod too?esbuild is blazing but lacks some advanced optimizations and plugin maturity for production bundles, so Vite uses Rollup for the prod build. This dev/prod tool split is occasionally a source of "works in dev, breaks in build" surprises — test the production build, don't just trust dev.
Architecture / LLD lensThe trend is moving build tooling to native-speed languages (esbuild in Go, Turbopack/SWC in Rust) because JS parsing/transforming JS is the bottleneck at scale. The other shift is incremental/lazy compilation — only build what's requested or changed, not the world. Both target the same enemy: dev feedback loop latency, which dominates engineer productivity.
Say it out loud"Vite for new SPAs — it serves native ESM in dev so startup is instant and stays flat as the app grows, using esbuild for speed, then Rollup for the prod bundle. Webpack when I inherit it or need its mature plugin ecosystem. Turbopack on modern Next.js. The underlying shift is from bundle-everything-upfront to lazy native-ESM dev, and moving tooling to Rust and Go for speed."
13 Managing feature flags
The one-sentence purpose: feature flags decouple deploy from release. You ship code to production turned off, then flip it on for whoever you choose, whenever you choose — no redeploy.
What they unlock
- Gradual rollout: enable for 1% → 10% → 100%, watching metrics.
- Kill switch: something's on fire → flip off instantly, no rollback deploy.
- A/B testing: show variant A to half, B to the other half.
- Trunk-based development: merge incomplete features to main behind a flag instead of long-lived branches.
- Targeting: beta users, internal staff, specific plans/regions.
Implementation
// Provider wraps the app; flags evaluated per-user from a service
<FlagProvider userId={user.id} attributes={{ plan: user.plan, region }}>
<App />
</FlagProvider>
// Consume with a hook
function Checkout() {
const newFlow = useFlag('new-checkout-flow');
return newFlow ? <NewCheckout /> : <LegacyCheckout />;
}
- Tools: LaunchDarkly (the standard), Split.io, Unleash (open source), or a homegrown service + config endpoint.
- Client vs server eval: client-side is simple but flag values are visible to users (don't gate security with them); server-side keeps logic private and avoids flicker.
Caveat — flags are tech debtEvery flag is a branch in your code = 2× the states to test. Stale flags rot — they become permanent dead branches nobody dares delete. Discipline: every flag gets an owner and an expiry/cleanup ticket. Remove it once the feature is fully rolled out. A codebase with 200 ancient flags is a maintenance nightmare.
Architecture / LLD lensFlags turn release into a runtime decision instead of a deploy event — that's the architectural shift enabling continuous delivery. But it pushes complexity into combinatorial state: N flags = 2^N possible app states, most untested. Mitigate by keeping flags short-lived, scoping them narrowly, and treating long-lived config (plan tiers) differently from temporary release flags. Avoid flag interdependencies — flag B's behavior depending on flag A's state is where the combinatorics bite.
Say it out loud"Feature flags decouple deploy from release — ship dark, flip on for a cohort, kill instantly if it breaks. They enable gradual rollouts, A/B tests, and trunk-based dev. I'd use LaunchDarkly or Unleash with a useFlag hook and per-user targeting. The discipline that matters is treating flags as tech debt — each one doubles the state space, so they get owners and expiry dates and get deleted after rollout."
14 Monitoring & logging frontend issues (Sentry, Datadog)
Frame it as three distinct questions you're answering with different tools: What broke? How does it feel for real users? What are people actually doing?
| Question | Category | Tools |
| What broke & why? | Error tracking | Sentry — exceptions, stack traces, source maps, session replay |
| How does it perform for real users? | RUM / APM | Datadog, New Relic — web vitals, traces, latency in the field |
| What are users doing? | Product analytics | PostHog, Amplitude — funnels, events, retention |
What I actually wire up
- Error boundaries → Sentry: catch React render errors and report them with component stack.
- Source map upload in CI: the killer detail — without it, prod stack traces are minified gibberish (
a.b.c is not a function). Upload maps on deploy so traces map back to real source.
- Release tracking: tag errors with the release/commit so you know which deploy introduced a spike — and regressions are obvious.
- web-vitals → analytics: field performance data (see Q8).
- Alerting: error-rate spike or new error type → Slack/PagerDuty.
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
release: import.meta.env.VITE_COMMIT_SHA, // tie errors to a deploy
tracesSampleRate: 0.1, // sample perf traces (cost control)
integrations: [Sentry.replayIntegration()], // session replay of the crash
});
Architecture / LLD lensThe principle is observability: you can't fix what you can't see, and prod is a black box without instrumentation. The design trade-offs are sampling (100% capture is expensive and noisy → sample traces, keep all errors), PII scrubbing (session replay can capture passwords/PII → mask sensitive fields), and cardinality/cost (every custom tag multiplies storage). Good observability is deliberate about what to capture, not "log everything."
Say it out loud"Three layers: Sentry for error tracking with source maps and release tagging so prod traces are readable and I know which deploy caused a spike; Datadog RUM for real-user web vitals and latency; and product analytics like PostHog for behavior. The detail people miss is uploading source maps in CI — without it your stack traces are minified noise. And I sample traces and scrub PII because observability has real cost."
🌐 System Design (Frontend Focus)
15 Design a dashboard that scales to 10K concurrent users
First, reframe it like a senior would: "10K concurrent" is mostly a backend/infra problem — the static frontend scales trivially. The real frontend challenge is real-time data fan-out and rendering efficiency." Saying that up front shows you know where the hard part actually lives.
1. The app shell scales for free
The HTML/JS/CSS are just files. Put them on a CDN and 10K or 10M users cost the same per-user — edge nodes serve cached static assets. Zero origin load. This part is solved.
2. Real-time data is the actual problem
Why WebSockets don't trivially scale to 10KA naive "every user holds a WebSocket, server pushes every update to everyone" means each data change fans out to 10K sockets — and each server can only hold so many open connections. The fixes:
- SSE (Server-Sent Events) if data flows one-way (server→client) — lighter than WebSocket, auto-reconnects, rides plain HTTP.
- Managed pub/sub (Ably, Pusher, or a Redis pub/sub + message broker behind your own gateway) to handle connection fan-out at scale instead of your app server.
- Server-side throttling/batching: don't push 50 updates/sec to the client — coalesce into one update every 250ms. The human eye can't use more anyway.
3. Rendering — don't melt the client
- Granular subscriptions: each widget subscribes to its data slice, so one metric tick re-renders one widget, not the whole dashboard. (Selector store — Q1/Q3.)
- Virtualization for big tables/lists (Q3) — constant DOM size.
- Aggregate server-side: ship computed summaries, not 100k raw rows for the client to crunch.
- Web workers for any heavy client-side computation so the main thread stays responsive (protects INP).
4. Resilience & data freshness
- Reconnect with exponential backoff on socket drop; degrade gracefully to polling if sockets fail.
- Optimistic UI for user actions; stale-while-revalidate for reads.
- Backpressure: if updates arrive faster than render, drop/coalesce intermediate frames — show the latest, not every one.
Architecture / LLD lensThe mental model: separate the read path from the live path. Initial load = cacheable HTTP through CDN. Live updates = pub/sub with fan-out pushed off your app servers onto infrastructure built for it. The frontend's job is to subscribe granularly, throttle/coalesce, and render only deltas. The scaling bottleneck is connections and fan-out, not React — so the design moves that concern to the right layer (broker/edge) and keeps the client doing minimal, localized work.
Say it out loud"The static app scales via CDN trivially — the real challenge is real-time fan-out. I'd push connection handling to managed pub/sub or SSE, batch and throttle updates server-side, and on the client subscribe each widget granularly so one tick doesn't re-render the whole board. Aggregate server-side, virtualize big tables, offload heavy compute to web workers, and reconnect with backoff degrading to polling. The bottleneck is connections, not React, so I move that to infrastructure built for it."
16 Ensuring consistent design and behavior across modules
Consistency doesn't come from telling people to be careful — it comes from making the consistent path the only easy path. You enforce it with tooling, not discipline.
The layers of enforcement
- Design tokens — one source of truth for color/space/type (Q5). Modules can't drift because they all consume the same tokens.
- Shared component library — every module uses the same Button, Modal, Table. Behavior is consistent because it's literally the same code.
- Linting + formatting as gates: ESLint, Prettier, Stylelint, run in CI and pre-commit hooks (Husky + lint-staged) so non-conforming code can't merge.
- Shared TypeScript types / contracts — modules agree on data shapes; a contract change breaks the build, not production.
- Visual regression (Chromatic) — catches unintended visual drift in PRs automatically.
- Documentation (Storybook) — devs reach for the existing component because they can find and see it.
For micro-frontends specifically
The hard caseWhen modules are independently deployed apps (micro-frontends), consistency is harder because they don't share a build. Solutions: a versioned shared design-system package all MFEs depend on; Module Federation to share singleton dependencies (one React, one design system) at runtime; and contract testing so a shared API change can't silently break a consumer.
Architecture / LLD lensThis is the DRY principle applied to UI + "make the right thing the easy thing." The architectural choice is centralization vs autonomy: a shared library maximizes consistency but couples teams to its release cadence; full autonomy maximizes team speed but causes drift. The usual answer is shared primitives + tokens (centralized), composed freely per module (autonomous) — consistency at the foundation, flexibility at the edges. Automated gates (CI, visual regression) are what make consistency scale past the point where humans can police it.
Say it out loud"I make consistency the path of least resistance: shared tokens and a component library so modules use identical building blocks, enforced by ESLint, Prettier, and visual regression in CI so drift can't merge. For micro-frontends, a versioned design-system package plus Module Federation for shared singletons and contract testing. The principle is centralize the primitives, stay autonomous in composition, and automate the gates so consistency scales beyond human review."
17 Code splitting and lazy loading routes
The whole point: a user visiting the login page shouldn't download the admin dashboard's code. Route-level splitting maps your bundle to your navigation so people download only what they use.
The standard pattern
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./routes/Dashboard'));
const Settings = lazy(() => import('./routes/Settings'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
lazy(() => import(...)) tells the bundler to split that route into its own chunk, fetched only when the route is hit. Suspense shows a fallback during the fetch.
The senior detail — prefetching to hide the latency
The trade-off lazy loading introducesLazy loading shrinks the initial bundle but adds a fetch delay when you navigate — click a link, wait for the chunk. The fix is prefetching: load the next route's chunk before the user clicks, on hover or when the link enters the viewport or during idle time. The user never feels the delay because the chunk's already there. Frameworks (Next.js) prefetch visible links automatically.
// Prefetch on hover — chunk is ready by the time they click
<Link
to="/dashboard"
onMouseEnter={() => import('./routes/Dashboard')}
>Dashboard</Link>
Where to draw split boundaries
- Routes — the primary, highest-leverage boundary.
- Heavy below-the-fold / on-interaction widgets — charts, rich-text editors, maps, modals.
- Large conditional features — admin-only panels, rarely-used flows.
Caveat — don't over-splitSplitting every tiny component creates a request waterfall — dozens of round-trips, each with latency, often slower than one slightly bigger chunk. Split at meaningful boundaries (routes, heavy features), not everywhere. Measure the network panel.
Architecture / LLD lensThis is lazy loading + spatial/temporal locality applied to code delivery. The design tension: initial load time vs navigation latency. Aggressive splitting wins TTI (time-to-interactive) but risks janky navigation; prefetching resolves the tension by shifting the load earlier in time, off the critical path. The art is choosing boundaries that match real user journeys — split where behavior diverges, prefetch along the likely path.
Say it out loud"I split at route boundaries with React.lazy and dynamic imports so users download only the page they visit, then lazy-load heavy widgets like charts and editors. The catch is navigation latency, which I hide by prefetching the next chunk on hover or idle. The discipline is not over-splitting — too many tiny chunks become a request waterfall that's slower than one. I draw boundaries along real user journeys."
18 Ensuring accessibility (a11y) and cross-browser compatibility
Accessibility — semantics first, ARIA last
The principle that drives everythingThe #1 a11y mistake is <div onClick> instead of <button>. A real <button> is keyboard-focusable, fires on Enter/Space, announces its role to screen readers, and works for free. A clickable div gives a screen-reader user nothing. So: use semantic HTML first; reach for ARIA only when no native element fits. "No ARIA is better than bad ARIA" — wrong ARIA is worse than none.
- Semantic HTML:
<button>, <nav>, <main>, <label> for inputs, heading hierarchy.
- Keyboard navigation: every interactive element reachable and operable by keyboard; visible focus indicators; logical tab order.
- Focus management: open a modal → trap focus inside + return it on close. (This is why headless libs like Radix are worth it — Q5.)
- Color contrast: WCAG AA — 4.5:1 for body text.
- Screen-reader labels:
aria-label on icon buttons, alt on images, live regions for dynamic updates.
How to test a11y
- Automated:
eslint-plugin-jsx-a11y (catches issues as you type), axe DevTools, Lighthouse a11y audit. Catches ~30–40% — the mechanical stuff.
- Manual: the other 60% — unplug your mouse and navigate by keyboard; turn on VoiceOver (Mac) / NVDA (Windows) and listen to your key flows. Automation can't tell you if the experience makes sense.
Cross-browser compatibility
browserslist — declare your target browsers in one place; Babel, PostCSS, and Autoprefixer all read it.
- Autoprefixer / PostCSS — adds vendor prefixes automatically based on your targets.
- Babel — transpiles modern JS to what your targets support; polyfills (core-js) for missing APIs.
- Feature detection over UA sniffing — check
if ('IntersectionObserver' in window), don't parse the user-agent string (brittle, spoofable).
- Progressive enhancement — core functionality works everywhere; enhancements layer on where supported.
- Cross-browser testing: Playwright runs your suite across Chromium/Firefox/WebKit; BrowserStack/Sauce for real-device matrices.
Architecture / LLD lensBoth reduce to the same principle: build on a robust, standards-based foundation and layer enhancements on top — progressive enhancement. Semantic HTML is accessible and cross-browser by default because it's the platform's own contract. The architectural discipline is "don't reinvent what the platform gives you" — native elements, native APIs with feature detection, declared browser targets driving automated tooling. You make correctness the default and treat a11y/compat as build-time gates (lint, axe, Playwright matrix), not a manual QA afterthought.
Say it out loud"Accessibility starts with semantic HTML — a real button is keyboard-operable and screen-reader-friendly for free; ARIA is the last resort, because bad ARIA is worse than none. I automate with eslint-plugin-jsx-a11y and axe, then manually test keyboard-only and a screen reader on key flows since automation only catches a third. For cross-browser, browserslist drives Autoprefixer and Babel, I use feature detection over UA sniffing, and Playwright tests across engines. Both come down to progressive enhancement — build on the platform, layer on top."