Frontend Developer Interview Prep
Your complete preparation portal for Senior Frontend Developer interviews. Covering React, TypeScript, Build Tools, Performance, State Management, Testing, and more.
Your Progress 0%
Click "Mark as Studied" at the bottom of each section to track your progress.
📖 How to Use This Guide
Navigate through sections using the sidebar.
Read concepts and study the code examples.
Expand Q&A accordions and try answering before revealing.
Mark sections as studied to track progress.
Search any topic using the search bar at the top.
⚛️ React.js — Deep Dive
Master React hooks, component patterns, Fiber architecture, and React 18 concurrent features that enterprise interviewers focus on.
🎣 React Hooks — Complete Reference
useState — Local State Management
The most fundamental hook. Manages local component state with a getter and setter pair. On every state update, the component re-renders.
// Basic usage
const [count, setCount] = useState<number>(0);
// Functional update — always use when new state depends on old
setCount(prev => prev + 1);
// Object state — always spread, React doesn't deep merge
const [user, setUser] = useState<User>({ name: '', email: '' });
setUser(prev => ({ ...prev, name: 'Alice' }));
// Lazy initializer — runs only once (expensive computation)
const [data, setData] = useState<number[]>(() => computeExpensiveData());useEffect — Side Effects & Lifecycle
Runs after paint. Handles subscriptions, data fetching, DOM manipulation, and timers. Return a cleanup function to prevent memory leaks.
// Run after every render (no deps)
useEffect(() => { document.title = 'App'; });
// Run once on mount (empty deps)
useEffect(() => { fetchData(); }, []);
// Run when deps change
useEffect(() => { fetchUser(userId); }, [userId]);
// With cleanup — ALWAYS clean up to prevent memory leaks
useEffect(() => {
const timer = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(timer); // cleanup on unmount or before next run
}, []);
// Abort fetch on unmount / re-run
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err); });
return () => controller.abort();
}, [id]);useCallback & useMemo — Memoization
useMemo memoizes the return value. useCallback memoizes the function itself. Don't overuse — memoization has its own cost.
// useMemo — memoize expensive computed value
const filteredList = useMemo(
() => items.filter(i => i.active && i.category === category),
[items, category] // recomputes only when these change
);
// useCallback — memoize function reference (pass to child component)
const handleSubmit = useCallback((data: FormData) => {
api.post('/submit', data);
}, []); // stable reference, child using React.memo won't re-render
// When NOT to memoize: cheap computations — overhead cost > benefit
// BAD: const doubled = useMemo(() => count * 2, [count]);
// GOOD: const doubled = count * 2;useRef — Mutable Refs & DOM Access
// DOM access
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();
// Persist mutable value WITHOUT triggering re-render
const renderCount = useRef(0);
renderCount.current += 1; // won't cause re-render
// Store previous value
function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => { ref.current = value; });
return ref.current;
}
// Store interval/timer ID
const timerRef = useRef<NodeJS.Timeout>();
timerRef.current = setInterval(tick, 1000);
clearInterval(timerRef.current);useReducer — Complex State Logic
Prefer useReducer when next state depends on previous state and multiple sub-values are updated together. Similar to Redux but local.
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET'; payload: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT': return state + 1;
case 'DECREMENT': return state - 1;
case 'RESET': return action.payload;
default: return state;
}
}
function Counter() {
const [count, dispatch] = useReducer(reducer, 0);
return (
<div>
<span>{count}</span>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'RESET', payload: 0 })}>Reset</button>
</div>
);
}Custom Hooks — Reusable Logic
// useFetch — generic data fetching hook
function useFetch<T>(url: string) {
const [state, setState] = useState<{
data: T | null; loading: boolean; error: Error | null;
}>({ data: null, loading: true, error: null });
useEffect(() => {
let cancelled = false;
setState(s => ({ ...s, loading: true }));
fetch(url)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
.catch(error => { if (!cancelled) setState({ data: null, loading: false, error }); });
return () => { cancelled = true; };
}, [url]);
return state;
}
// useLocalStorage — synced with localStorage
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
try { return JSON.parse(localStorage.getItem(key) ?? '') ?? initial; }
catch { return initial; }
});
const set = useCallback((v: T) => {
setValue(v);
localStorage.setItem(key, JSON.stringify(v));
}, [key]);
return [value, set] as const;
}
// useDebounce — debounce any value
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}🧩 Component Patterns
| Pattern | Use Case | Example |
|---|---|---|
| React.memo | Prevent child re-render when parent re-renders | Pure list items, cards |
| HOC (Higher Order Component) | Cross-cutting: auth, logging, analytics | withAuth(Component) |
| Render Props | Share stateful logic via function prop | <Mouse render={(pos) => ...} /> |
| Compound Components | Shared implicit state between parent/child | Tabs, Accordion, Select |
| Portal | Render outside parent DOM hierarchy | Modal, Toast, Dropdown |
| Error Boundary | Catch JS errors in component subtree | Section-level fallback UI |
Error Boundaries (Class Component — only way)
class ErrorBoundary extends React.Component<
{ children: ReactNode; fallback?: ReactNode },
{ hasError: boolean; error?: Error }
> {
state = { hasError: false };
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
// Log to Sentry, Datadog, etc.
}
render() {
if (this.state.hasError) {
return this.props.fallback || <div>Something went wrong.</div>;
}
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={<ErrorPage />}>
<Dashboard />
</ErrorBoundary>React Portal — Render Outside DOM Tree
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, children }: ModalProps) {
if (!isOpen) return null;
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.getElementById('modal-root')! // target outside #root
);
}⚙️ React Fiber & Reconciliation
React Fiber (introduced in React 16) is a complete rewrite of React's core reconciliation algorithm. It enables incremental rendering — breaking rendering work into chunks and spreading it over multiple frames.
Keys matter! Use stable unique IDs as keys in lists. Using array index as key causes bugs when list order changes — React misidentifies nodes and applies updates to wrong elements.
🆕 React 18 — Concurrent Features
// 1. Automatic Batching — multiple setState calls batched in async code too
setTimeout(() => {
setCount(c => c + 1);
setName('Alice');
// React 18: ONE re-render. React 17: TWO re-renders.
}, 0);
// 2. startTransition — mark low-priority updates
import { startTransition, useTransition } from 'react';
const [isPending, startTransition] = useTransition();
startTransition(() => {
setSearchQuery(value); // non-urgent — can be interrupted
});
// isPending = true while transitioning → show spinner
// 3. useDeferredValue — defer re-render of expensive child
const deferredQuery = useDeferredValue(searchQuery);
// Pass deferredQuery to slow component — React 18 prioritizes urgent updates
// 4. Suspense for Data Fetching (with compatible libraries)
<Suspense fallback={<Skeleton />}>
<UserProfile userId={id} /> // throws promise until data ready
</Suspense>
// 5. useId — stable ID for SSR/hydration
const id = useId();
<label htmlFor={id}>Email</label>
<input id={id} />🎯 React Interview Q&A
What is the difference between useMemo and useCallback?
▼useMemo memoizes a computed value — it runs a function and caches its return value. Use it when you have expensive calculations.
useCallback memoizes a function reference — it returns the same function instance across re-renders. Use it when passing callbacks to child components wrapped in React.memo to prevent unnecessary re-renders.
const sorted = useMemo(() => [...list].sort(), [list]); // memoized value
const onClick = useCallback(() => doSomething(id), [id]); // memoized functionHow does React reconciliation/diffing work?
▼React creates a virtual DOM — a lightweight JS object tree mirroring real DOM. When state/props change, it creates a new virtual tree and diffs it against the old one using these heuristics:
- Different element type → destroy old tree, build new one from scratch
- Same element type → update only changed attributes, recurse children
- Keys in lists → stable key = element tracked across re-renders without rebuild
Only the minimal set of changes is applied to the real DOM (expensive operation). Fiber makes this process interruptible.
What are the rules of hooks?
▼- Only call at the top level — never inside loops, conditions, or nested functions. React relies on call order to associate hook state with the right hook.
- Only call from React functions — functional components or custom hooks (not regular JS functions).
ESLint plugin eslint-plugin-react-hooks enforces these rules automatically.
What is the useEffect cleanup function and why is it important?
▼The function returned from useEffect runs:
- Before the component unmounts
- Before the next effect runs (if deps changed)
Without cleanup, you get memory leaks: stale state updates after unmount, duplicate subscriptions, orphaned timers. Always clean up: clearInterval, removeEventListener, controller.abort(), subscription.unsubscribe().
Controlled vs Uncontrolled components — when to use which?
▼Controlled
- React state is source of truth
value+onChangealways together- Instant validation, conditional disabling
- Full programmatic control
Uncontrolled
- DOM is source of truth
- Access via
ref.current.value - Less code for simple forms
- Needed for file inputs
Rule: Use controlled for complex forms needing validation. Use uncontrolled (or React Hook Form) when performance matters in large forms.
How would you prevent unnecessary re-renders in React?
▼- React.memo — wrap pure components to skip re-renders if props unchanged
- useCallback — stable function references to memoized children
- useMemo — memoize expensive derived values
- State colocation — keep state as close to where it's used as possible
- Split contexts — separate fast-changing from slow-changing context
- Keys — use stable unique IDs to help React identify unchanged nodes
- Avoid object/array creation in render — moves object creation outside or into useMemo
🔀 useState vs useRef — When to Use Which
One of the most frequently asked interview questions. Both persist values across renders, but only useState triggers a re-render.
| Aspect | useState | useRef |
|---|---|---|
| Triggers re-render | ✅ Yes — on every update | ❌ No — changing .current never re-renders |
| Access pattern | const [val, setVal] = useState() | ref.current |
| Persists across renders | ✅ Yes | ✅ Yes |
| Synchronous read | ⚠️ Stale in closures | ✅ Always current value |
| Best for | UI state affecting what user sees | DOM refs, timer IDs, previous values, render counters |
// useState — changing triggers re-render
const [count, setCount] = useState(0);
setCount(5); // re-renders component
// useRef — changing does NOT re-render
const renderCount = useRef(0);
renderCount.current += 1; // no re-render!
// Common pattern: track previous value
function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => { ref.current = value; });
return ref.current; // previous render's value
}
const prevCount = usePrevious(count);
// useRef for interval — avoids stale closure
const callbackRef = useRef(callback);
useEffect(() => { callbackRef.current = callback; }, [callback]);
// Interval always calls latest callback without re-registering
const timerRef = useRef<NodeJS.Timeout>();
timerRef.current = setInterval(() => callbackRef.current(), 1000);
// Rule of thumb:
// Does the user need to SEE this value? → useState
// Is it behind-the-scenes infrastructure? → useRef🔀 Conditional Rendering — All Patterns
How to conditionally render JSX — a fundamental React concept asked in many interviews.
// 1. if / else (outside JSX)
function Component({ isAdmin }: { isAdmin: boolean }) {
if (!isAdmin) return <Unauthorized />;
return <AdminPanel />;
}
// 2. Ternary — inline in JSX
<div>{isLoggedIn ? <Dashboard /> : <Login />}</div>
// 3. Short-circuit && — renders right side only if left is truthy
<div>
{isLoading && <Spinner />}
{error && <ErrorMessage msg={error} />}
{data && <UserList users={data} />}
</div>
// ⚠️ Pitfall: 0 renders as "0" in JSX!
// BAD:
{count && <Badge>{count}</Badge>} // renders "0" when count is 0
// GOOD:
{count > 0 && <Badge>{count}</Badge>}
{Boolean(count) && <Badge>{count}</Badge>}
// 4. Nullish coalescing — render fallback
{user?.name ?? 'Anonymous'}
// 5. Switch pattern for multiple states
function StatusBadge({ status }: { status: Status }) {
const config = {
active: { label: 'Active', color: 'green' },
inactive: { label: 'Inactive', color: 'grey' },
pending: { label: 'Pending', color: 'yellow' },
};
const { label, color } = config[status];
return <span className={`badge-${color}`}>{label}</span>;
}
// 6. renderIf utility
const renderIf = (condition: boolean, element: ReactNode) =>
condition ? element : null;📅 React History & Blank Screen Debugging
When Were Function Components Introduced?
Function components existed since React's beginning (2013) as "stateless functional components" — they were just pure UI functions. However, they were severely limited until:
| Version | Year | What Changed |
|---|---|---|
| React 0.14 | 2015 | Stateless Functional Components officially introduced |
| React 16.3 | 2018 | New lifecycle methods, createRef, Context API |
| React 16.8 | 2019 | Hooks introduced — function components can now have state & side effects! |
| React 17 | 2020 | No new features — JSX transform, event system changes |
| React 18 | 2022 | Concurrent features, automatic batching, useId, useDeferredValue |
"Function components were always in React, but React 16.8 (February 2019) introduced Hooks, which gave them the full power of class components. Since then, class components have been de-emphasized and the community migrated to function components exclusively."
🖥️ User Sees Blank Screen — Debugging Approach
// Step-by-step blank screen debugging:
// 1. Open DevTools → Console tab
// Look for: JavaScript errors, unhandled rejections, network failures
// 2. Check the Network tab
// Is index.html loading? Are JS/CSS bundles loading (200 OK)?
// Any 404 on assets? Check publicPath/base in Vite/Webpack.
// 3. Check React Error Boundary
// If no error boundary → React silently unmounts on uncaught error
// Add ErrorBoundary to catch and display the error:
<ErrorBoundary fallback={<ErrorPage />}>
<App />
</ErrorBoundary>
// 4. Check React DevTools
// Is the component tree rendering? Or is it empty?
// 5. Common Causes:
// - Uncaught error in render (null.property access)
// - Missing default export in a lazy-loaded chunk
// - Wrong environment variable (VITE_API_URL=undefined)
// - Failed to hydrate (SSR/SSG mismatch)
// - Route path mismatch (SPA not serving index.html for all routes)
// - CORS error blocking bootstrap API call
// 6. Production-only blank screen:
// - Missing sourcemaps → Sentry/console error shows minified code
// - Wrong base URL in Vite build (base: '/' vs base: '/app/')
// - Missing nginx try_files config for SPA routing⛓️ Async/Await vs .then() Chains + Auth HOC
Converting .then() Chains → async/await
// ❌ Nested .then() chains (hard to read, hard to handle errors)
function loadDashboard(userId: string) {
return getUser(userId)
.then(user => getOrders(user.id)
.then(orders => getProducts(orders.map(o => o.productId))
.then(products => ({ user, orders, products }))
)
)
.catch(err => console.error(err)); // catches all but loses context
}
// ✅ async/await — sequential, readable
async function loadDashboard(userId: string) {
try {
const user = await getUser(userId);
const orders = await getOrders(user.id); // depends on user
const products = await getProducts(orders.map(o => o.productId)); // depends on orders
return { user, orders, products };
} catch (err) {
if (err instanceof UserNotFoundError) {
// handle specific error
}
throw err; // re-throw for caller
}
}
// ✅ Parallel when no dependency — use Promise.all
async function loadSidebar(userId: string) {
const [notifications, messages, profile] = await Promise.all([
getNotifications(userId), // these three run in PARALLEL
getMessages(userId),
getProfile(userId),
]);
return { notifications, messages, profile };
}
// ✅ Error handling per call
async function robustLoad() {
const results = await Promise.allSettled([callA(), callB(), callC()]);
results.forEach(r => {
if (r.status === 'fulfilled') process(r.value);
else logError(r.reason);
});
}Auth + Role-Based Guard — HOC Pattern
Copying auth/role logic into every component is a maintenance disaster. Centralize it.
// ── Option 1: HOC (Higher Order Component) ────────────
function withAuth<P extends object>(
Component: ComponentType<P>,
requiredRole?: UserRole
) {
return function AuthGuard(props: P) {
const { user, isLoading } = useAuth();
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
if (requiredRole && !user.roles.includes(requiredRole))
return <Navigate to="/forbidden" replace />;
return <Component {...props} />;
};
}
// Usage
const AdminPanel = withAuth(AdminPanelRaw, 'admin');
const AccountManager = withAuth(AccountManagerRaw, 'manager');
// ── Option 2: Route Guard Component (cleaner for routing) ─
function AuthGuard({ roles }: { roles?: UserRole[] }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
if (roles && !roles.some(r => user.roles.includes(r)))
return <Navigate to="/403" replace />;
return <Outlet />;
}
// Usage in router
<Routes>
<Route element={<AuthGuard />}> {/* any logged-in user */}
<Route path="/dashboard" element={<Dashboard />} />
<Route element={<AuthGuard roles={['admin']} />}> {/* admins only */}
<Route path="/admin" element={<AdminPanel />} />
</Route>
</Route>
</Routes>
// ── Option 3: Custom hook ──────────────────────────────
function useRequireRole(role: UserRole) {
const { user } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (user && !user.roles.includes(role)) navigate('/forbidden');
}, [user, role, navigate]);
return user?.roles.includes(role) ?? false;
}🔷 TypeScript — Proficiency
TypeScript adds static typing to JavaScript. Master types, generics, utility types, and advanced patterns used in enterprise React applications.
📝 Types vs Interfaces
Both define object shapes, but they have key differences. As a rule: use interface for public API shapes (extendable), use type for everything else.
// ── INTERFACE ──────────────────────────────────────
interface User {
readonly id: number;
name: string;
email?: string; // optional
}
// Extension
interface Admin extends User {
role: 'admin' | 'superadmin';
permissions: string[];
}
// Declaration merging (interfaces can be merged — types cannot)
interface Window { myLib: object; } // adds to existing Window type
// ── TYPE ALIAS ─────────────────────────────────────
type ID = string | number; // union
type Point = { x: number; y: number };
type AdminUser = User & { role: string }; // intersection
// Type for function signatures
type Handler = (event: MouseEvent) => void;
type Predicate<T> = (item: T) => boolean;
// Tuple types
type Pair<A, B> = [A, B];
const entry: Pair<string, number> = ['age', 30];When to Use Which
| Feature | interface | type |
|---|---|---|
| Object shapes | ✅ Preferred | ✅ Works |
| Unions / Intersections | ❌ | ✅ Only way |
| Primitives, Tuples | ❌ | ✅ Only way |
| Declaration merging | ✅ | ❌ |
| extends keyword | ✅ | ❌ (use &) |
| implements (class) | ✅ | ✅ |
🔧 Generics — Write Reusable, Type-safe Code
Generics allow you to write functions, interfaces, and classes that work with multiple types while preserving type information.
// Generic function
function identity<T>(arg: T): T { return arg; }
identity<string>('hello'); // explicit
identity(42); // inferred
// With constraint — T must have a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
// Generic with keyof — type-safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const name = getProperty({ name: 'Alice', age: 30 }, 'name'); // string
// Generic React component
interface ListProps<T> {
items: T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => React.ReactNode;
}
function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return <ul>{items.map(item => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}
// Generic API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: string;
}
async function fetchTyped<T>(url: string): Promise<ApiResponse<T>> {
const res = await fetch(url);
return res.json();
}
const users = await fetchTyped<User[]>('/api/users');🛠️ Utility Types — Complete Reference
TypeScript ships built-in utility types. Knowing all of these is expected at senior level.
interface User {
id: number; name: string; email: string; role: 'admin' | 'user';
}
Partial<User> // { id?: number; name?: string; ... } — all optional
Required<User> // all fields required (removes ?)
Readonly<User> // all fields readonly — immutable
Pick<User, 'id' | 'name'> // { id: number; name: string }
Omit<User, 'role'> // User without 'role' field
// Record — key/value map
type RoleMap = Record<'admin' | 'user', string[]>;
// { admin: string[]; user: string[] }
// Exclude / Extract on union types
type Status = 'active' | 'inactive' | 'pending';
Exclude<Status, 'pending'> // 'active' | 'inactive'
Extract<Status, 'active' | 'pending'> // 'active' | 'pending'
NonNullable<string | null | undefined> // string
// Function utilities
type Fn = (a: string, b: number) => boolean;
ReturnType<Fn> // boolean
Parameters<Fn> // [string, number]
ConstructorParameters<typeof Date> // constructor args
// Awaited — unwrap Promise
type Result = Awaited<Promise<User[]>>; // User[]🧩 Advanced Types
Discriminated Unions — Type-safe State Machines
// Discriminated union — 'status' is the discriminant
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function render<T>(state: FetchState<T>) {
switch (state.status) {
case 'idle': return <Placeholder />;
case 'loading': return <Spinner />;
case 'success': return <Data data={state.data} />; // TS knows: state.data exists
case 'error': return <Error msg={state.error} />; // TS knows: state.error exists
}
}Conditional Types & Mapped Types
// Conditional type
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
// infer — extract type from conditional
type UnpackArray<T> = T extends (infer U)[] ? U : T;
type C = UnpackArray<string[]>; // string
// Mapped type — transform all keys
type Optional<T> = { [K in keyof T]?: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Stringify<T> = { [K in keyof T]: string };
// Template literal types
type EventName = `on${Capitalize<string>}`;
type CSSProp = `margin-${'top' | 'right' | 'bottom' | 'left'}`;
// 'margin-top' | 'margin-right' | 'margin-bottom' | 'margin-left'TypeScript with React — Common Patterns
// Typing props with children
interface CardProps {
title: string;
variant?: 'primary' | 'secondary';
children: React.ReactNode;
className?: string;
}
// Event handlers
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); };
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {};
// forwardRef with TypeScript
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, ...rest }, ref) => <input ref={ref} {...rest} />
);
// Context with TypeScript
interface ThemeContextType { theme: 'dark' | 'light'; toggle: () => void; }
const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined);
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
return ctx;
}🎯 TypeScript Interview Q&A
What is a discriminated union and when would you use it?
▼A discriminated union is a union type where each member has a common literal property (the discriminant) that uniquely identifies it. TypeScript uses this to narrow types in conditional checks.
Use it for: state machines (loading/success/error), action types in Redux, API response variants. It enables exhaustive type checking — TypeScript will error if you miss a case.
What is the difference between unknown and any?
▼any — disables type checking entirely. The variable can be assigned to any type and any operation is allowed. Dangerous and should be avoided.
unknown — type-safe counterpart of any. You can't do anything with an unknown value without first narrowing its type (with typeof, instanceof, or type guard). Use unknown for values you don't know yet (e.g., JSON.parse result, API responses, catch blocks).
const x: unknown = JSON.parse(data);
// x.toFixed() ← ERROR: can't call method on unknown
if (typeof x === 'number') x.toFixed(); // ← OK after narrowing
// catch blocks: error is unknown in TS 4+
catch (err: unknown) {
if (err instanceof Error) console.log(err.message);
}Explain type narrowing with examples.
▼Type narrowing is the process of refining a broad type to a more specific one within a code block. Methods:
- typeof —
typeof x === 'string' - instanceof —
x instanceof Date - in —
'name' in user - Discriminated union —
if (state.status === 'success') - Type guard function —
function isUser(x): x is User - Truthiness narrowing —
if (value) - Assertion functions —
assert(condition, msg)
🎯 TypeScript Enums
Enums allow defining a set of named constants. Frequently asked in TypeScript interviews.
// ── Numeric Enum (default) ─────────────────────────────
enum Direction { Up, Down, Left, Right } // Up=0, Down=1...
enum Status { Active = 1, Inactive = 2, Pending = 3 } // custom start
// ── String Enum (preferred for readability) ────────────
enum HttpMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
}
const method: HttpMethod = HttpMethod.GET;
// ── const enum (inlined at compile time — no JS object emitted) ──
const enum Color { Red = 'RED', Blue = 'BLUE', Green = 'GREEN' }
// const col = Color.Red; → compiled to: const col = "RED";
// Use when you don't need to iterate over enum values at runtime
// ── Accessing enum values ─────────────────────────────
const val = Status.Active; // 1
const key = Status[1]; // 'Active' (numeric enum reverse mapping)
// ── Enum as function parameter ────────────────────────
function setDirection(dir: Direction) { ... }
setDirection(Direction.Up);
setDirection(0); // also valid (numeric enum accepts number) — be careful!
// ── When to use enum vs union type ───────────────────
// Union type (simpler, no runtime overhead, preferred in modern TS):
type UserRole = 'admin' | 'user' | 'moderator';
// Enum (when you need a real object at runtime to iterate over values):
enum UserRole { Admin = 'admin', User = 'user', Moderator = 'moderator' }
Object.values(UserRole); // ['admin', 'user', 'moderator']Numeric enums have a reverse mapping (both Enum[0] and Enum['key'] work). String enums don't. This can cause confusion. Many TypeScript experts prefer as const union types over enums for simple cases.
♾️ The never Type
never represents values that never occur. It's the bottom type — assignable to every type, but nothing is assignable to it. Used for exhaustiveness checks, unreachable code, and type narrowing.
// 1. Function that never returns (throws or infinite loop)
function throwError(msg: string): never {
throw new Error(msg); // never returns normally
}
function forever(): never {
while (true) {} // infinite loop
}
// 2. Exhaustiveness checking — most powerful use!
type Shape = 'circle' | 'square' | 'triangle';
function getArea(shape: Shape): number {
switch (shape) {
case 'circle': return Math.PI * 10;
case 'square': return 100;
case 'triangle': return 50;
default: {
// TypeScript will error here if you add a new Shape but forget this case!
const exhaustiveCheck: never = shape;
throw new Error(`Unhandled shape: ${exhaustiveCheck}`);
}
}
}
// 3. never in conditional types
type NonNullable<T> = T extends null | undefined ? never : T;
// Exclude null/undefined from a type
// 4. Filter from union type
type FilterOut<T, U> = T extends U ? never : T;
type OnlyStrings = FilterOut<string | number | boolean, number | boolean>;
// Result: string
// 5. never vs void
// void: function returns undefined (or nothing explicitly)
function log(msg: string): void { console.log(msg); }
// never: function CANNOT return (throws or loops forever)🚀 TypeScript 5.x New Features
TypeScript current stable is 5.x (not 7). If the interviewer says "TypeScript 7", they likely mean recent versions (5.x) or are testing if you know current versions.
| Feature | Version | What It Does |
|---|---|---|
| Decorators (Stage 3) | TS 5.0 | Official ECMAScript decorators for classes and methods |
| const Type Parameters | TS 5.0 | function fn<const T>(arr: T) — infers literal types |
| Variadic Tuple Labels | TS 5.0 | Labeled tuple elements for better IDE tooltips |
| satisfies Operator | TS 4.9 | Type check without widening — best of both worlds |
| using / await using | TS 5.2 | Explicit resource management (auto-dispose) |
| Const Assertions Improved | TS 5.x | Better inference for as const |
// satisfies operator (TS 4.9+) — validate type without widening
const config = {
port: 3000,
host: 'localhost',
debug: true,
} satisfies Partial<ServerConfig>;
// config.port is still type 3000 (literal), not number!
// vs 'as': config.port would be number (widened)
// const type parameter (TS 5.0)
function createRoutes<const T extends string[]>(routes: T): T {
return routes;
}
const routes = createRoutes(['/home', '/about']); // type: readonly ['/home', '/about']
// Stage 3 Decorators (TS 5.0)
function log(target: any, context: ClassMethodDecoratorContext) {
return function (this: any, ...args: any[]) {
console.log(`Calling ${String(context.name)}`);
return target.apply(this, args);
};
}
class Service {
@log
fetchData() { ... }
}
// using — Explicit Resource Management (TS 5.2)
function getFile() {
using file = openFile('./data.txt'); // automatically disposed!
return file.read();
} // file.dispose() called here automatically🔧 Build Tools — Vite, Webpack, npm
Enterprise-level comfort with build tooling: Vite configuration, Webpack internals, .npmrc registry settings, SSL, TSLint/ESLint, and minification.
⚡ Vite — Modern Build Tool
In dev: Vite serves files as native ES modules — the browser imports them directly. No bundling = instant server start and near-zero HMR. In production: Uses Rollup to create optimized bundles.
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react()],
// Path aliases
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@hooks': path.resolve(__dirname, './src/hooks'),
}
},
// Dev server
server: {
port: 3000,
open: true,
// SSL — self-signed cert for HTTPS dev
https: {
key: './certs/localhost-key.pem',
cert: './certs/localhost.pem',
},
// Proxy API requests (avoids CORS in dev)
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
// Production build
build: {
outDir: 'dist',
sourcemap: true, // source maps for debugging
minify: 'terser', // or 'esbuild' (faster, slightly less optimal)
terserOptions: {
compress: { drop_console: true },
},
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {
// Manual chunk splitting for better caching
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-router': ['react-router-dom'],
'vendor-redux': ['@reduxjs/toolkit', 'react-redux'],
},
// Hash filenames for cache busting
chunkFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
},
// Define global constants (replaced at build time)
define: {
'process.env.API_URL': JSON.stringify(env.VITE_API_URL),
},
};
});📦 Webpack — Enterprise Build Tool
Core Concepts
| Concept | Description | Example |
|---|---|---|
| Entry | Starting point for dependency graph | ./src/index.tsx |
| Output | Where to emit bundles | dist/[name].[contenthash].js |
| Loaders | Transform non-JS files (run per file) | ts-loader, css-loader, babel-loader |
| Plugins | Extend build process globally | HtmlWebpackPlugin, MiniCssExtract |
| Mode | Enables optimizations | development | production |
| splitChunks | Automatic code splitting | vendor, commons bundles |
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash:8].js', // contenthash for long-term caching
clean: true, // clean dist before build
publicPath: '/',
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
alias: { '@': path.resolve(__dirname, 'src') },
},
module: {
rules: [
// TypeScript
{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
// CSS Modules
{
test: /\.module\.css$/,
use: [MiniCssExtractPlugin.loader, { loader: 'css-loader', options: { modules: true } }]
},
// Regular CSS
{ test: /(?📋 .npmrc — Registry & SSL Configuration
Interviewers at enterprise roles will ask about configuring .npmrc for private registries, SSL certificates, and corporate proxy settings. Know this file well.
# ─── Registry Settings ────────────────────────────────
# Default public registry
registry=https://registry.npmjs.org/
# Scoped package uses a private registry (GitHub Package Registry)
@myorg:registry=https://npm.pkg.github.com/
# Another scope → Nexus / Artifactory
@company:registry=https://nexus.company.com/repository/npm-proxy/
# ─── Authentication ────────────────────────────────────
# Token for GitHub Packages
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
# Token for Nexus (base64 encoded user:password)
//nexus.company.com/repository/npm-proxy/:_auth=${NEXUS_AUTH_TOKEN}
# Or username/password
//nexus.company.com/repository/npm-proxy/:username=myuser
//nexus.company.com/repository/npm-proxy/:_password=${NEXUS_PASSWORD_BASE64}
# ─── SSL / Certificate Settings ────────────────────────
# Verify SSL (default true — should stay true in production)
strict-ssl=true
# Custom CA certificate (corporate/internal CA)
cafile=/path/to/company-ca.crt
# Trust specific cert fingerprint (alternative to cafile)
# cert=/path/to/client-cert.pem # client certificate
# key=/path/to/client-key.pem # client key
# Disable SSL verification (ONLY for local dev with self-signed certs)
# strict-ssl=false ← NEVER in CI/CD
# ─── Proxy Settings ────────────────────────────────────
# Corporate proxy
proxy=http://proxy.company.com:8080
https-proxy=http://proxy.company.com:8080
# Bypass proxy for internal URLs
noproxy=.company.com,localhost,127.0.0.1
# ─── Performance & Behavior ────────────────────────────
cache=/path/to/custom/npm-cache
prefer-offline=true # Use cache when possible
save-exact=true # Lock exact versions (no ^)
legacy-peer-deps=true # For packages with peer dep conflicts
# ─── Engine Constraints ────────────────────────────────
engine-strict=true # Fail if Node version doesn't match engines field🔍 ESLint & TypeScript Linting
TSLint was deprecated in 2019. Modern TypeScript projects use ESLint + @typescript-eslint. Know this for the interview.
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": { "jsx": true },
"project": ["./tsconfig.json"]
},
"plugins": ["@typescript-eslint", "react-hooks", "react", "import"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:import/typescript"
],
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-floating-promises": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react/react-in-jsx-scope": "off",
"no-console": ["warn", { "allow": ["warn", "error"] }]
},
"settings": {
"react": { "version": "detect" }
}
}Tree Shaking & Minification
| Concept | Description | Requirement |
|---|---|---|
| Tree Shaking | Removes unused exports from final bundle | ES modules (import/export) — not CommonJS (require) |
| Minification | Reduces file size: removes whitespace, shortens names | Terser (Webpack/Vite), esbuild (fastest) |
| Scope Hoisting | Merges module scopes to reduce overhead | ES modules + production mode |
| Dead Code Elimination | Removes unreachable code paths | Terser + TypeScript const enums |
🎯 Build Tools Interview Q&A
What is tree shaking and how does it work?
▼Tree shaking is dead code elimination — bundlers analyze your code and remove exports that are never imported. It relies on ES module static analysis (import/export), which can be statically analyzed at build time (unlike CommonJS require() which is dynamic).
How to enable: Use ES modules, set sideEffects: false in package.json, use production mode in Webpack/Vite, avoid import * as X from 'lib' (use named imports instead).
What is contenthash and why is it used in filenames?
▼Content hash generates a unique hash based on file content. When you build: main.a3f9b2c1.js. If the file content changes between builds, the hash changes → browsers invalidate cache and fetch the new file. If content is unchanged, hash is unchanged → browsers use long-cached version. This enables aggressive long-term caching (Cache-Control: max-age=31536000, immutable) for maximum performance.
How would you configure a private npm registry for a corporate environment?
▼In .npmrc:
- Set scoped registry:
@myorg:registry=https://nexus.company.com/npm/ - Add auth token:
//nexus.company.com/npm/:_authToken=${NPM_TOKEN} - If corporate CA:
cafile=/etc/ssl/certs/company-ca.crt - If behind proxy:
proxy=http://proxy.company.com:8080
Store tokens in environment variables — never commit them. In CI/CD, set NPM_TOKEN as a secret.
📦 npm install — What Happens with devDependencies
# npm install (no flags) — installs BOTH dependencies AND devDependencies
npm install
# npm install --production (or --omit=dev) — skips devDependencies
# Used in Docker production builds to reduce image size
npm install --omit=dev
npm ci --omit=dev
# npm ci — clean install from package-lock.json
# Used in CI/CD: deterministic, fails if lock file out of sync, deletes node_modules first
npm ci
# What goes in dependencies vs devDependencies?
# dependencies: react, react-dom, axios, redux ← needed at runtime
# devDependencies: vite, typescript, jest, eslint, @types/* ← only build/test time
# Impact: Docker multi-stage build
# In builder stage: npm ci (installs everything to build)
# In production stage: npm ci --omit=dev OR just copy built files
# Result: smaller production image (no build tools inside)⚙️ Binary Configuration in a Project
"Binary configuration" refers to executable scripts defined in package.json under the bin field, and the .bin folder inside node_modules. When you install a package, its binaries are symlinked here and become runnable via npx or npm scripts.
{
"name": "my-cli-tool",
"bin": {
"my-tool": "./bin/cli.js" // registers 'my-tool' command
},
"scripts": {
// npm scripts can reference .bin executables directly
"build": "vite build", // uses node_modules/.bin/vite
"lint": "eslint src", // uses node_modules/.bin/eslint
"test": "jest",
"type-check": "tsc --noEmit",
"format": "prettier --write src",
"analyze": "npx vite-bundle-visualizer",
// Composite scripts
"ci": "npm run lint && npm run type-check && npm test",
"pre-commit": "npm run lint && npm run format"
},
"engines": {
"node": ">=18.0.0", // enforced with engine-strict=true in .npmrc
"npm": ">=9.0.0"
}
}
// The .bin folder:
// node_modules/.bin/vite ← symlink to vite's CLI
// node_modules/.bin/eslint ← symlink to eslint's CLI
// When you run 'npm run build', npm adds node_modules/.bin to PATH first📊 Lighthouse Performance Score — Breakdown
Lighthouse is Google's open-source tool for auditing web performance. The Performance Score (0-100) is a weighted average of these metrics:
| Metric | Weight | Good Target | What It Measures |
|---|---|---|---|
| FCP — First Contentful Paint | 10% | < 1.8s | First text/image painted |
| SI — Speed Index | 10% | < 3.4s | How quickly content is visually populated |
| LCP — Largest Contentful Paint | 25% | < 2.5s | Largest element visible |
| TBT — Total Blocking Time | 30% | < 200ms | Time main thread was blocked (long tasks) |
| CLS — Cumulative Layout Shift | 25% | < 0.1 | Visual stability |
🟢 90-100 = Good 🟡 50-89 = Needs Improvement 🔴 0-49 = Poor
How to Improve Lighthouse Score
🚀 Performance Optimization
Code splitting, lazy loading, memoization, bundle optimization, Web Vitals, and production-grade performance strategies.
✂️ Code Splitting & Lazy Loading
// Route-based code splitting (most impactful)
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Reports = lazy(() => import('./pages/Reports'));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
// Component-level splitting (large modals, charts, editors)
const RichTextEditor = lazy(() =>
import('./components/RichTextEditor').then(m => ({ default: m.RichTextEditor }))
);
// Prefetch — load before user navigates (on hover/focus)
function NavItem({ to, label }: { to: string; label: string }) {
const prefetch = () => import('./pages/' + to);
return (
<Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
{label}
</Link>
);
}📊 Web Vitals — Performance Metrics
| Metric | Good | Needs Improvement | What It Measures |
|---|---|---|---|
| LCP — Largest Contentful Paint | < 2.5s | 2.5s–4s | Loading performance — when largest element is visible |
| INP — Interaction to Next Paint | < 200ms | 200–500ms | Responsiveness — delay from user interaction to visual update |
| CLS — Cumulative Layout Shift | < 0.1 | 0.1–0.25 | Visual stability — unexpected content movement |
| FCP — First Contentful Paint | < 1.8s | 1.8–3s | When first text/image is painted |
| TTFB — Time to First Byte | < 800ms | 800ms–1.8s | Server response time |
Measuring in Code
// Using web-vitals library
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric: Metric) {
console.log(metric); // send to your analytics endpoint
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
// React Performance API
import { Profiler } from 'react';
<Profiler id="Dashboard" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) console.warn(`${id} took ${actualDuration}ms to render`);
}}>
<Dashboard />
</Profiler>📋 Bundle Optimization Strategies
| Strategy | How | Impact |
|---|---|---|
| Tree Shaking | ES modules, production mode | High — removes unused code |
| Code Splitting | Dynamic imports, route splitting | High — reduces initial bundle |
| Vendor Chunking | manualChunks for stable deps | High — better cache reuse |
| Image Optimization | WebP format, responsive srcset, lazy load | High for image-heavy sites |
| Compression | Gzip/Brotli on server (nginx/CDN) | High — 60-80% size reduction |
| Preloading | <link rel="preload"> for critical assets | Medium — faster LCP |
| Avoid large deps | moment → date-fns, lodash → native | Medium — smaller bundle |
| Subresource Integrity | CDN asset hash validation | Security benefit |
List Virtualization (Large Data)
// react-window — render only visible rows (handles 100k+ items)
import { FixedSizeList } from 'react-window';
interface RowProps { index: number; style: React.CSSProperties; }
function VirtualList({ items }: { items: Item[] }) {
const Row = ({ index, style }: RowProps) => (
<div style={style} className="row">
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}🎯 Performance Interview Q&A
How would you diagnose and fix a slow React application?
▼Step 1 — Profile first, optimize second:
- React DevTools → Profiler tab → record interactions → identify components with high
actualDuration - Chrome Performance tab → record → look for long tasks, layout thrashing
- Lighthouse → Web Vitals scores, opportunities
- Bundle Analyzer → identify large chunks
Step 2 — Fix based on findings:
- Re-render storms →
React.memo,useCallback, split contexts - Large bundle → code splitting, lazy loading, vendor chunking
- Slow list → virtualize with react-window
- Network → React Query caching, prefetching, pagination
- Images → WebP, lazy loading, CDN
- CPU-intensive → Web Worker
What causes Cumulative Layout Shift (CLS) and how do you fix it?
▼Causes: Images without width/height, dynamically injected content (ads, banners), async loaded fonts, animations using top/left instead of transform.
Fixes:
- Always set
widthandheighton images (or use aspect-ratio CSS) - Reserve space for dynamic content (skeleton screens, min-height)
- Use
font-display: optionalor preload fonts - Use
transformfor animations (not top/left/margin)
🗃️ State Management
Redux Toolkit, Context API, React Query, and Zustand — when to use each, trade-offs, and real patterns used in enterprise apps.
🔄 Redux Toolkit — Modern Redux
RTK eliminates boilerplate: no action creators, no switch statements, Immer for immutable updates built-in.
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
// Async thunk for API calls
export const fetchUsers = createAsyncThunk(
'users/fetchAll',
async (_, { rejectWithValue }) => {
try {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json() as User[];
} catch (err) {
return rejectWithValue((err as Error).message);
}
}
);
export const createUser = createAsyncThunk(
'users/create',
async (userData: Partial<User>) => {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(userData),
headers: { 'Content-Type': 'application/json' },
});
return res.json() as Promise<User>;
}
);
// Slice — combines actions + reducer
const usersSlice = createSlice({
name: 'users',
initialState: {
list: [] as User[],
selected: null as User | null,
status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed',
error: null as string | null,
},
reducers: {
// Immer makes this mutation-safe
selectUser: (state, action: PayloadAction<User>) => {
state.selected = action.payload;
},
clearSelected: state => { state.selected = null; },
updateUser: (state, action: PayloadAction<User>) => {
const idx = state.list.findIndex(u => u.id === action.payload.id);
if (idx !== -1) state.list[idx] = action.payload;
},
},
extraReducers: builder => {
builder
.addCase(fetchUsers.pending, state => { state.status = 'loading'; state.error = null; })
.addCase(fetchUsers.fulfilled, (state, { payload }) => {
state.status = 'succeeded';
state.list = payload;
})
.addCase(fetchUsers.rejected, (state, { payload }) => {
state.status = 'failed';
state.error = payload as string;
})
.addCase(createUser.fulfilled, (state, { payload }) => {
state.list.push(payload);
});
},
});
export const { selectUser, clearSelected, updateUser } = usersSlice.actions;
export default usersSlice.reducer;
// Selectors (memoized with createSelector)
import { createSelector } from '@reduxjs/toolkit';
const selectUsersState = (state: RootState) => state.users;
export const selectAllUsers = createSelector(selectUsersState, s => s.list);
export const selectUsersStatus = createSelector(selectUsersState, s => s.status);
export const selectActiveUsers = createSelector(
selectAllUsers,
users => users.filter(u => u.active) // memoized — recalculates only when users changes
);import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers, selectAllUsers, selectUsersStatus } from './usersSlice';
function UserList() {
const dispatch = useDispatch<AppDispatch>();
const users = useSelector(selectAllUsers);
const status = useSelector(selectUsersStatus);
useEffect(() => { dispatch(fetchUsers()); }, [dispatch]);
if (status === 'loading') return <Spinner />;
if (status === 'failed') return <ErrorMsg />;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}🌐 React Query (TanStack Query) — Server State
Key insight: Redux manages client state. React Query manages server state (fetched data, loading states, caching). For API data, React Query is often better than Redux.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Fetching with automatic caching, background refetch, stale-while-revalidate
function UserList() {
const { data: users, isLoading, isError, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
staleTime: 5 * 60 * 1000, // data fresh for 5 minutes
gcTime: 10 * 60 * 1000, // keep in cache 10 minutes after unmount
retry: 2, // retry failed requests 2 times
refetchOnWindowFocus: true, // refetch when user returns to tab
});
// Dependent query — runs only when userId is available
const { data: orders } = useQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrders(userId),
enabled: !!userId, // don't run until userId exists
});
// Mutation — create/update/delete
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: (data: NewUser) =>
fetch('/api/users', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
// Optimistic update — update UI before server responds
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ['users'] });
const prev = queryClient.getQueryData(['users']);
queryClient.setQueryData(['users'], (old: User[]) => [...old, { ...newUser, id: 'temp' }]);
return { prev }; // rollback context
},
onError: (err, vars, ctx) => {
queryClient.setQueryData(['users'], ctx?.prev); // rollback on error
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); // refetch fresh data
},
});
}📊 State Management Comparison
| Solution | Best For | When NOT to Use | Complexity |
|---|---|---|---|
| useState | Local, simple state | Shared across many components | Low |
| useReducer | Complex local state with multiple actions | Needs to be shared globally | Low-Med |
| Context API | Low-frequency global state (theme, auth, locale) | High-frequency updates (causes re-renders) | Medium |
| Redux Toolkit | Complex client state, DevTools needed, large team | Simple apps, only server state | High |
| Zustand | Simple global state, less boilerplate than Redux | Need strict Redux patterns | Low |
| React Query | Server state: API data, caching, sync | Client-only state (no server) | Medium |
| Jotai/Recoil | Atomic state, fine-grained subscriptions | Team unfamiliar with atomic patterns | Medium |
Most enterprise apps use a combination: React Query for server state + Redux Toolkit for complex client state + Context for auth/theme + useState for local UI state.
🎯 State Management Interview Q&A
When would you choose Redux over Context API?
▼Choose Redux when:
- App has complex state with many interdependent updates
- Need Redux DevTools for time-travel debugging
- State is shared across many unrelated components
- Need middleware (thunks, sagas, logging)
- Team size demands predictable, strict patterns
Choose Context API when: state updates are infrequent (theme, auth, i18n), app is small-medium, you want minimal dependencies.
Performance caveat: Context triggers re-renders for all consumers when any value changes. Split contexts by update frequency or use useMemo/memo to optimize.
Explain Redux Toolkit createAsyncThunk — what does it give you?
▼createAsyncThunk automatically generates three action types: pending, fulfilled, and rejected. You handle them in extraReducers. Benefits:
- No manual action creator code
- Automatic loading/error state tracking
rejectWithValuefor custom error payloads- AbortController support via
signalparameter - Deduplication with
conditionoption
🧪 Testing — Jest & React Testing Library
Unit, integration, and component testing. RTL philosophy, async patterns, mocking strategies, and coverage.
🧠 RTL Philosophy — Test Behavior, Not Implementation
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds. Test what the user sees and does, not internal state or implementation details.
| ❌ Avoid (implementation) | ✅ Prefer (behavior) |
|---|---|
| Testing internal state directly | Testing what renders on screen |
| Testing component method calls | Testing user interactions (click, type) |
| Snapshots of full component tree | Assertions on specific elements/text |
| Shallow rendering | Full render with RTL |
Query Priority (use in this order)
// 1. ByRole — most accessible, matches how screen readers work
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 2 });
// 2. ByLabelText — form elements
screen.getByLabelText('Email address');
// 3. ByText — user-visible text
screen.getByText('Welcome back!');
// 4. ByTestId — last resort (getByTestId)
screen.getByTestId('submit-btn'); // only if no semantic query works
// Variants:
// getBy — throws if not found or multiple (1 expected)
// findBy — async, returns Promise, waits for element
// queryBy — returns null if not found (for asserting absence)
// getAllBy, findAllBy, queryAllBy — return arrays✅ Component Testing Patterns
import { render, screen, fireEvent, userEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// ── Basic Component Test ──────────────────────────────
describe('LoginForm', () => {
const user = userEvent.setup(); // use userEvent over fireEvent (more realistic)
it('shows validation error on empty submit', async () => {
render(<LoginForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
it('calls onSubmit with credentials', async () => {
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'secret123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
});
});
it('disables button while loading', () => {
render(<LoginForm onSubmit={jest.fn()} isLoading />);
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
});
});
// ── Async Test (API call) ─────────────────────────────
describe('UserList', () => {
it('renders users after fetch', async () => {
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
});
render(<UserList />);
// Loading state
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Wait for data to load
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
it('shows error state when fetch fails', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network Error'));
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});🎭 Mocking Strategies
// ── jest.mock — module mocking ────────────────────────
jest.mock('../api/usersApi', () => ({
fetchUsers: jest.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]),
createUser: jest.fn().mockResolvedValue({ id: 2, name: 'Bob' }),
}));
// Reset mocks between tests
beforeEach(() => jest.clearAllMocks());
// ── jest.spyOn — spy on specific method ──────────────
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Do test...
consoleSpy.mockRestore();
// ── MSW (Mock Service Worker) — best practice ─────────
// Intercepts at network level — tests work same in browser and Node
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/users', () => HttpResponse.json([{ id: 1, name: 'Alice' }])),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 99, ...body }, { status: 201 });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Override handler per test
it('handles 500 error', () => {
server.use(http.get('/api/users', () => HttpResponse.error()));
// test error state...
});🎯 Testing Interview Q&A
What is the difference between getBy, queryBy, and findBy queries?
▼- getBy: Synchronous. Throws if element not found or if multiple found. Use when element should definitely be present.
- queryBy: Synchronous. Returns
nullif not found (doesn't throw). Use to assert element is absent:expect(queryBy...).not.toBeInTheDocument(). - findBy: Asynchronous. Returns a Promise. Waits up to 1000ms for element to appear. Use for elements rendered asynchronously (after API calls).
What's the testing pyramid and how does it apply to frontend?
▼The testing pyramid (bottom to top): Unit Tests → Integration Tests → E2E Tests.
- Unit (many): Test isolated functions, hooks, utilities. Fast, cheap. Jest.
- Integration (some): Test components with their dependencies (API mocked via MSW). RTL. Catch wiring bugs.
- E2E (few): Full user flows in real browser. Playwright / Cypress. Slow, expensive. Cover critical paths only.
For React, the integration layer (RTL) gives the most value per test — you test a component with real hooks, routing, and mocked APIs.
🌐 REST APIs & Integration
Fetch API, Axios with interceptors, authentication patterns, error handling, and React Query for server state.
📡 Axios — Enterprise HTTP Client
import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
// Create typed Axios instance
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
});
// ── Request Interceptor ───────────────────────────────
api.interceptors.request.use(
config => {
const token = getAuthToken(); // from store, cookie, localStorage
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
error => Promise.reject(error)
);
// ── Response Interceptor ──────────────────────────────
api.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as any;
// Token refresh on 401
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await refreshAccessToken();
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest); // retry original request
} catch {
logout(); // refresh failed — send to login
}
}
// Global error handling
if (error.response?.status === 403) navigate('/forbidden');
if (error.response?.status === 503) showMaintenanceBanner();
return Promise.reject(error);
}
);
// ── Typed API service ─────────────────────────────────
export const usersApi = {
getAll: () => api.get<User[]>('/users').then(r => r.data),
getById: (id: number) => api.get<User>(`/users/${id}`).then(r => r.data),
create: (data: CreateUserDto) => api.post<User>('/users', data).then(r => r.data),
update: (id: number, data: Partial<User>) => api.put<User>(`/users/${id}`, data).then(r => r.data),
delete: (id: number) => api.delete(`/users/${id}`),
};
export default api;🔐 Authentication Patterns
| Pattern | Storage | Pros | Cons |
|---|---|---|---|
| JWT in localStorage | localStorage | Simple, works across tabs | XSS vulnerable |
| JWT in HttpOnly Cookie | Cookie | XSS safe, automatic sending | CSRF risk (mitigate with SameSite) |
| Access + Refresh Token | Memory + Cookie | Short-lived access, secure refresh | Complex implementation |
| Session Cookie | Server-side | No token management | Not stateless, scaling needs |
Don't store JWTs in localStorage in production! Store access tokens in memory (JS variable) and refresh tokens in HttpOnly cookies. localStorage is accessible by any JS including XSS injected scripts.
🎯 REST API Interview Q&A
What is CORS and how do you handle it on the frontend?
▼CORS (Cross-Origin Resource Sharing) is a browser security mechanism that blocks requests to a different origin (domain/protocol/port) than the page's origin.
Frontend handling:
- Use a dev proxy (Vite/Webpack proxy config) to avoid CORS in development
- Pass
credentials: 'include'in fetch for cookie-based auth - Don't bypass CORS with browser extensions — the backend must properly set CORS headers
Backend must set: Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers. With Spring Boot: @CrossOrigin or global CORS config.
Explain the difference between REST and GraphQL.
▼REST
- Multiple endpoints per resource
- Fixed response shape
- Over/under fetching possible
- Simple caching (HTTP cache)
- Industry standard, widely supported
GraphQL
- Single endpoint
- Client specifies exact fields needed
- No over/under fetching
- Complex caching (Apollo/normalized)
- Strong typing with schema
📡 HTTP Status Codes — Complete Reference
| Code | Name | When Used |
|---|---|---|
| 2xx — Success | ||
| 200 | OK | Standard success. GET, PUT responses. |
| 201 | Created | Resource created. POST response (include Location header). |
| 204 | No Content | Success but no body. DELETE responses. |
| 4xx — Client Errors (your fault) | ||
| 400 | Bad Request | Invalid request body, missing required fields, validation error. |
| 401 | Unauthorized | Not authenticated — missing or invalid token. Prompt login. |
| 403 | Forbidden | Authenticated but not authorized — wrong role/permission. |
| 404 | Not Found | Resource doesn't exist. Also used to avoid leaking info (vs 403). |
| 405 | Method Not Allowed | Endpoint exists but not for that HTTP method (POST on GET-only endpoint). |
| 408 | Request Timeout | Server timed out waiting for client request. |
| 409 | Conflict | Conflicting state — duplicate resource, optimistic lock failure. |
| 413 | Content Too Large | Request body exceeds server's max size limit. Common with file uploads. Fix: increase nginx client_max_body_size or Spring Boot spring.servlet.multipart.max-file-size. |
| 422 | Unprocessable Entity | Syntactically valid but semantically incorrect. Used in REST APIs for business validation. |
| 429 | Too Many Requests | Rate limiting. Show backoff timer in UI, implement retry with exponential backoff. |
| 5xx — Server Errors (their fault) | ||
| 500 | Internal Server Error | Generic server error. Check server logs. Show user-friendly error message. |
| 502 | Bad Gateway | Upstream service returned invalid response. Proxy/load balancer issue. |
| 503 | Service Unavailable | Server down or overloaded. Maintenance mode. Retry with backoff. |
| 504 | Gateway Timeout | Upstream service timed out. Backend is slow or unreachable. |
401 Unauthorized: "I don't know who you are" — redirect to login.
403 Forbidden: "I know who you are, but you can't do this" — show access denied UI.
Received when uploading large files. Fix on frontend: validate file size before upload, compress images. Fix on backend/infra: increase client_max_body_size in nginx, spring.servlet.multipart.max-file-size in Spring Boot. Frontend should also show a clear error message: "File too large. Maximum size is 10MB."
♿ Accessibility (a11y)
WCAG principles, ARIA attributes, keyboard navigation, focus management, and testing for enterprise-grade accessible apps.
🏛️ WCAG 2.1 — Four Principles (POUR)
WCAG Levels
| Level | Requirement | Examples |
|---|---|---|
| A — Minimum | Essential a11y | Alt text, keyboard access, no color-only info |
| AA — Standard | Most legal requirements | 4.5:1 contrast, focus visible, labels, error ID |
| AAA — Enhanced | Best possible a11y | 7:1 contrast, sign language, extended audio desc |
🏷️ ARIA & Semantic HTML
Always prefer semantic HTML over ARIA. A <button> element is inherently accessible — you'd need ARIA to replicate its behavior on a <div>. First rule of ARIA: don't use ARIA if native HTML can do it.
// ── Semantic HTML — always prefer ────────────────────
<nav aria-label="Main navigation">
<ul><li><a href="/">Home</a></li></ul>
</nav>
<main id="main-content">...</main>
<aside aria-label="Related articles">...</aside>
// ── Skip Navigation ────────────────────────────────
<a href="#main-content" className="skip-link">
Skip to main content
</a>
// ── Accessible Button ─────────────────────────────
// Good:
<button type="button" onClick={handleClose}>
<span aria-hidden="true">✕</span>
<span className="visually-hidden">Close dialog</span>
</button>
// ── Interactive ARIA ──────────────────────────────
<button
aria-expanded={isOpen}
aria-controls="dropdown-menu"
aria-haspopup="listbox"
>Options</button>
<ul id="dropdown-menu" role="listbox" aria-label="Options">...</ul>
// ── Form Accessibility ────────────────────────────
<label htmlFor="email">Email address <span aria-hidden="true">*</span></label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid={!!emailError}
aria-describedby={emailError ? 'email-error' : undefined}
/>
{emailError && <p id="email-error" role="alert">{emailError}</p>}
// ── Loading State ──────────────────────────────────
<div aria-live="polite" aria-atomic="true">
{isLoading && <span>Loading results...</span>}
</div>
// ── Focus Management in Modal ──────────────────────
function Modal({ isOpen, onClose }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
// Trap focus inside modal
const focusableEls = modalRef.current?.querySelectorAll(
'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])'
);
// ... implement focus trap
}
}, [isOpen]);
return isOpen ? (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
tabIndex={-1} // makes div focusable
>
<h2 id="modal-title">Confirm Action</h2>
...
</div>
) : null;
}☕ Full Stack Exposure — Java & Spring Boot
Backend concepts a frontend developer needs: REST, Spring Boot basics, CORS, microservices, and API contracts.
☕ Spring Boot — What Frontend Devs Need to Know
// Typical Spring Boot REST Controller structure
@RestController // @Controller + @ResponseBody
@RequestMapping("/api/users") // base path
@CrossOrigin(origins = "http://localhost:3000") // CORS for dev
public class UserController {
@Autowired
private UserService userService;
@GetMapping // GET /api/users
public List<User> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return userService.findAll(PageRequest.of(page, size));
}
@GetMapping("/{id}") // GET /api/users/{id}
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return userService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping // POST /api/users
@ResponseStatus(HttpStatus.CREATED)
public User createUser(@RequestBody @Valid CreateUserDto dto) {
return userService.create(dto);
}
@PutMapping("/{id}") // PUT /api/users/{id}
public User updateUser(@PathVariable Long id, @RequestBody UpdateUserDto dto) {
return userService.update(id, dto);
}
@DeleteMapping("/{id}") // DELETE /api/users/{id}
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}Spring Boot Architecture Layers
| Layer | Annotation | Responsibility |
|---|---|---|
| Controller | @RestController | HTTP routing, request/response handling |
| Service | @Service | Business logic, orchestration |
| Repository | @Repository | Data access, JPA queries |
| Entity | @Entity | Database table mapping (JPA) |
| DTO | Plain class | Data transfer between layers/API |
🏗️ Microservices Architecture
// Use openapi-typescript to generate types from Swagger spec
// npx openapi-typescript https://api.example.com/openapi.json -o src/types/api.d.ts
// Then use in your code
import type { paths } from './types/api';
type User = paths['/users/{id}']['get']['responses']['200']['content']['application/json'];🐳 CI/CD & Docker
GitHub Actions pipelines, Dockerfile multi-stage builds, Kubernetes basics, and deployment strategies.
⚙️ GitHub Actions — CI/CD Pipeline
name: Frontend CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
IMAGE_NAME: myorg/frontend
jobs:
# ── Quality Checks ─────────────────────────────────
quality:
name: Lint, Type Check, Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm' # cache node_modules
- run: npm ci # deterministic install from package-lock
- run: npm run lint
- run: npm run type-check # tsc --noEmit
- run: npm test -- --coverage --ci
- name: Upload Coverage
uses: codecov/codecov-action@v3
with: { token: ${{ secrets.CODECOV_TOKEN }} }
# ── Build & Push Docker Image ──────────────────────
build:
name: Build Docker Image
needs: quality # only runs if quality passes
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
build-args: |
VITE_API_URL=${{ secrets.VITE_API_URL }}
# ── Deploy ─────────────────────────────────────────
deploy:
name: Deploy to Production
needs: build
runs-on: ubuntu-latest
environment: production # requires manual approval
steps:
- name: Deploy to Kubernetes
run: |
kubectl set image deployment/frontend frontend=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
kubectl rollout status deployment/frontend🐳 Docker — Multi-stage Dockerfile
# ─── Stage 1: Build ────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Install deps first (cached if package.json unchanged)
COPY package*.json ./
RUN npm ci --only=production=false
# Copy source and build
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# ─── Stage 2: Production ───────────────────────────
FROM nginx:1.25-alpine AS production
WORKDIR /usr/share/nginx/html
# Remove default nginx static content
RUN rm -rf ./*
# Copy built files from builder stage
COPY --from=builder /app/dist .
# Nginx config for SPA routing + gzip
COPY nginx.conf /etc/nginx/nginx.conf
# Create non-root user
RUN addgroup -g 101 -S nginx && \
chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx
USER nginx
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]server {
listen 80;
root /usr/share/nginx/html;
# SPA routing — all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip
gzip on;
gzip_types text/plain text/css application/javascript application/json;
}🎯 CI/CD Interview Q&A
What is the difference between npm install and npm ci?
▼- npm install: Installs deps. May update package-lock.json. Used during development.
- npm ci: Installs exactly from package-lock.json. Fails if lock file is out of sync. Deletes node_modules first. Never writes to lock file. Use in CI/CD for deterministic, reproducible builds.
What are deployment strategies? Blue-Green vs Rolling vs Canary?
▼| Strategy | How | Pros | Cons |
|---|---|---|---|
| Rolling | Gradually replace instances one-by-one | No extra infra cost | Brief mixed versions |
| Blue-Green | Two identical envs, switch traffic atomically | Zero downtime, instant rollback | Doubles infra cost |
| Canary | Route % of traffic to new version, increase gradually | Real-world testing, low risk | Complex routing, monitoring |
🧩 Micro Frontends (MFE)
Architecture pattern for splitting a large frontend into independently deployable micro-apps. Frequently asked in senior-level interviews at enterprise companies.
🤔 What is a Micro Frontend?
Micro Frontend (MFE) extends microservice principles to the frontend layer. Instead of one large monolithic React app, you split the UI into independently developed, built, and deployed frontend applications — each owned by a separate team.
MFE vs Monolith vs Modular Monolith
| Approach | Deploy | Team Scaling | Complexity | Best For |
|---|---|---|---|---|
| Monolith | One bundle | Hard at scale | Low | Small teams, startup |
| Modular Monolith | One bundle | Medium | Medium | Medium teams with feature modules |
| Micro Frontend | Per MFE | Excellent | High | Large enterprise, 5+ teams |
⚙️ Module Federation — Webpack 5
Module Federation is Webpack 5's built-in MFE solution. It lets one application dynamically load code from another application at runtime — no npm install required.
// HOST APP — shell that loads remote MFEs
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
// Maps 'checkout' to the remote app exposed at runtime URL
checkout: 'checkout@https://checkout.company.com/remoteEntry.js',
catalog: 'catalog@https://catalog.company.com/remoteEntry.js',
},
shared: {
// Shared deps — only one copy loaded, avoids duplication
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
// In host app — lazy load a remote component
const CheckoutPage = React.lazy(() => import('checkout/CheckoutPage'));
<Suspense fallback={<Spinner />}>
<CheckoutPage />
</Suspense>// REMOTE APP (Checkout MFE) — exposes its components
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js', // entry manifest loaded by host
exposes: {
'./CheckoutPage': './src/pages/CheckoutPage',
'./CartWidget': './src/components/CartWidget',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});🔗 Sharing State Between MFEs
This is the key challenge of MFEs. Since MFEs are separate apps, they can't share React Context or Redux store directly. Several patterns exist:
| Pattern | How | Best For | Drawback |
|---|---|---|---|
| URL / Query Params | Encode shared state in URL | Route-level state, filters, IDs | Limited size, not for sensitive data |
| Custom Events (window) | window.dispatchEvent(new CustomEvent('cart:updated', {detail: item})) | Loose coupling, simple messages | No type safety, global namespace |
| Shared Module Federation | Expose a shared store/event bus as a federated module | Complex shared state | Version coupling between MFEs |
| Browser Storage | localStorage / sessionStorage / cookies | Auth tokens, user preferences | Sync issues, storage limits |
| Props / Callbacks | Host passes props into mounted MFE | Parent-child communication | Only for parent-aware MFEs |
| Backend-driven | Both MFEs read from same API | Shared server state | Extra API calls, latency |
// Shared event bus (exposed as a federated module)
type EventMap = {
'user:login': { userId: string; role: string };
'cart:updated': { items: CartItem[]; total: number };
'auth:logout': {};
};
class EventBus {
private handlers = new Map<string, Set<Function>>();
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]) {
window.dispatchEvent(new CustomEvent(event, { detail: payload }));
}
on<K extends keyof EventMap>(event: K, handler: (payload: EventMap[K]) => void) {
window.addEventListener(event, (e: any) => handler(e.detail));
}
}
export const bus = new EventBus();
// In Checkout MFE:
bus.emit('cart:updated', { items, total }); // emit
// In Header MFE:
bus.on('cart:updated', ({ total }) => setCartTotal(total)); // listen🎯 MFE Interview Q&A
What is a Micro Frontend and why would you use it?
▼A Micro Frontend is an architectural approach where a web application is composed of semi-independent, smaller applications (MFEs), each developed, deployed, and maintained by a separate team. You'd use it when:
- Your organization has multiple teams working on the same frontend
- You want independent deployments — team A ships without waiting for team B
- The codebase is too large for one team to maintain
- Different sections need different tech stacks or upgrade cycles
When NOT to use: Small teams, simple apps, or when you don't have the DevOps maturity to manage independent deployment pipelines for each MFE.
What are the challenges of Micro Frontends?
▼- Shared state: No single store — need event bus, URL state, or shared federated module
- Styling conflicts: CSS from different MFEs can clash — use CSS Modules, Shadow DOM, or scoped naming
- Duplicate dependencies: Multiple MFEs loading React separately — use Module Federation
sharedconfig - Performance: Multiple entry points, multiple network requests — aggressive caching needed
- Testing: Integration testing across MFE boundaries is complex
- Auth consistency: Each MFE needs access to auth tokens — usually via shared cookie or federated auth module
Have you worked with Angular or Vue?
▼Honest answer template: "I've been primarily working with React, so my deep expertise is in the React ecosystem. However, I have a solid understanding of component-based architecture, which is the foundation of Angular and Vue as well. I'm familiar with the key differences: Angular is opinionated and full-featured (RxJS, dependency injection, TypeScript-first), Vue is more progressive and flexible. I'm confident I could pick up either framework quickly given my strong JS/TS fundamentals."
If asked about Python or Java: "While I'm primarily a frontend engineer, I've worked with Spring Boot APIs and understand backend concepts like REST, JPA, and service layers. I can read and contribute to backend code when needed."
🏛️ Architecture & Design Patterns
Scalable folder structures, design patterns in React context, SOLID principles, and enterprise frontend architecture.
📁 Feature-based Folder Structure
src/
├── features/ # Feature modules (domain-driven)
│ ├── auth/
│ │ ├── components/ # Auth-specific UI
│ │ │ ├── LoginForm.tsx
│ │ │ └── AuthGuard.tsx
│ │ ├── hooks/ # Auth-specific hooks
│ │ │ └── useAuth.ts
│ │ ├── services/ # API calls
│ │ │ └── authApi.ts
│ │ ├── store/ # Redux slice
│ │ │ └── authSlice.ts
│ │ ├── types/ # Feature types
│ │ │ └── auth.types.ts
│ │ └── index.ts # Public API (barrel export)
│ │
│ ├── users/
│ └── dashboard/
│
├── shared/ # Shared across features
│ ├── components/ # Design system components
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ └── Button.stories.tsx
│ │ └── Modal/
│ ├── hooks/ # Global custom hooks
│ │ ├── useDebounce.ts
│ │ └── useMediaQuery.ts
│ ├── utils/ # Pure utilities
│ └── types/ # Shared TypeScript types
│
├── api/ # HTTP client setup
│ ├── client.ts # Axios instance
│ └── interceptors.ts
│
├── store/ # Redux store setup
│ ├── index.ts # configureStore
│ └── rootReducer.ts
│
├── router/ # Routing config
│ └── AppRouter.tsx
│
└── App.tsxBarrel exports (index.ts): Each feature exports only its public API. Internal components stay private. This prevents tight coupling between features. Import as import { LoginForm } from '@/features/auth'.
🔨 Design Patterns in React
| Pattern | React Application | Example |
|---|---|---|
| Observer | Event system, subscriptions | Context API, Redux subscription, event emitter |
| Factory | Create components dynamically | Component registry, dynamic form fields |
| Singleton | One instance shared globally | Redux Store, Axios instance, QueryClient |
| Strategy | Swappable algorithm/behavior | Different sort strategies, payment processors |
| Decorator | Wrap component with extra behavior | HOCs (withAuth, withLogging) |
| Facade | Simplified interface to complex subsystem | API service layer hiding fetch details |
| Compound Component | Implicit state between parent/child | Tabs, Select, Accordion |
| Builder | Construct complex objects step by step | Form builders, query builders |
📐 SOLID Principles in Frontend
| Principle | Frontend Application |
|---|---|
| Single Responsibility | One component/hook does one thing. Split UserCard into UserAvatar + UserInfo. |
| Open/Closed | Open for extension, closed for modification. Use props/composition to extend components without editing them. |
| Liskov Substitution | Components implementing same interface are interchangeable. Generic List<T> works with any item type. |
| Interface Segregation | Don't force components to depend on props they don't use. Split large prop interfaces. |
| Dependency Inversion | Components depend on abstractions (interfaces, callback props), not concrete implementations. Inject services. |
🔍 Code Review & Debugging
What to look for in code reviews, common React bugs and their fixes, performance debugging, and security considerations.
✅ Code Review Checklist
TypeScript Quality
- No
anytypes without justification (useunknownor proper types) - No
astype assertions hiding real type errors - Utility types used appropriately (not manual reimplementation)
- Proper error handling with typed catch blocks
- Generic types for reusable functions
React Quality
- No missing useEffect dependencies (ESLint exhaustive-deps)
- No stale closures in event handlers
- Keys in lists are stable unique IDs (not array index)
- useEffect has cleanup function where needed
- No object/array literals in JSX without memoization (causes re-renders)
- Error boundaries around sections that could fail
- React.memo used appropriately (not everywhere — measure first)
Performance
- Large lists are virtualized
- Images have width/height attributes and lazy loading
- No unnecessary network requests in render
- Heavy computations are memoized or moved to Web Worker
Security
- No dangerouslySetInnerHTML with user content (XSS risk)
- Sensitive data not stored in localStorage
- Auth tokens in HttpOnly cookies, not JS-accessible storage
- Dependencies have no known vulnerabilities (npm audit)
🐛 Common React Bugs & Fixes
| Bug | Cause | Fix |
|---|---|---|
| Stale closure in useEffect | Dependency not in deps array; captures old value | Add to deps array or use functional update setState(prev => ...) |
| Infinite re-render loop | Object/array created in render inside deps array | useMemo for deps, or move outside component |
| Memory leak: "Can't update unmounted component" | setState called after unmount in async useEffect | Return cleanup that cancels async operation |
| Context re-render storm | Context value is new object on every render | Memoize context value with useMemo; split contexts |
| Wrong key causing state reset | Changing key when not intended | Use stable ID as key, not index or random |
| Multiple renders in dev (React 18) | StrictMode double-invokes effects | By design — effects must be idempotent, cleanup required |
Debugging the Stale Closure Bug
// ❌ Bug: count is always 0 inside the interval (stale closure)
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always logs 0!
setCount(count + 1); // Always sets to 1!
}, 1000);
return () => clearInterval(timer);
}, []); // count not in deps → stale closure
}
// ✅ Fix 1: Add count to deps (runs new effect each time)
useEffect(() => {
const timer = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(timer);
}, [count]);
// ✅ Fix 2: Functional update (no stale closure needed)
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // Always gets current value
}, 1000);
return () => clearInterval(timer);
}, []); // empty deps is now safe🎯 Interview Q&A — Master List
60+ curated interview questions with model answers. Cover all rounds: technical screening, coding, architecture, and behavioral.
⚛️ React Questions
What happens when you call setState inside useEffect?
▼Calling setState inside useEffect triggers a re-render. After the re-render, if the effect deps changed, useEffect runs again. This can cause an infinite loop if you update state that's in the dependency array without a condition. Always guard setState calls in effects with conditions or proper dependencies.
How would you implement a debounced search input?
▼function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
// Only fires when debouncedQuery changes (300ms after last keystroke)
useEffect(() => {
if (debouncedQuery) searchApi(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
function useDebounce<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const t = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return debounced;
}What is React.StrictMode and what does it do?
▼StrictMode is a development-only tool (no production effect) that:
- Double-invokes render, useState, useMemo, useReducer to detect side effects in these (must be pure)
- Double-invokes effects (mount → unmount → remount) to detect cleanup issues — if you see double API calls in dev, this is why
- Warns about deprecated APIs, legacy lifecycle methods
- Helps ensure your app is ready for React's concurrent features
How do you share state between sibling components?
▼Lift state up to the nearest common ancestor component. Pass state down as props and setter as callback prop. For deeply nested or widely shared state: Context API or Redux. State colocation principle: keep state as close to where it's used as possible — don't prematurely lift to global state.
🔷 TypeScript Questions
How do you make all properties of a type optional except one?
▼type RequireOnly<T, K extends keyof T> =
Partial<Omit<T, K>> & Required<Pick<T, K>>;
interface User { id: number; name: string; email: string; }
type UserUpdate = RequireOnly<User, 'id'>;
// { id: number; name?: string; email?: string; }What is the difference between interface and type for function signatures?
▼// Both work, but syntax differs
interface IHandler { (event: Event): void; }
type THandler = (event: Event) => void;
// interface can describe callable + properties
interface Formatter {
(value: string): string; // callable
locale: string; // property
format: (n: number) => string; // method
}🔧 Build & Performance Questions
What is the difference between devDependencies and dependencies in package.json?
▼- dependencies: Needed at runtime in production (React, React DOM, Axios, Redux)
- devDependencies: Only needed at development/build time (TypeScript, Vite, Jest, ESLint, @types/*). Not installed in production Docker builds using
npm ci --omit=dev - peerDependencies: Packages the consumer must provide (React for React component libraries)
How would you analyze and reduce a large bundle size?
▼- Visualize: Run
npx vite-bundle-visualizerorwebpack-bundle-analyzerto see what's taking space - Lazy load routes: Each page should be a separate chunk
- Replace heavy packages: moment.js (330kb) → date-fns (tree-shakeable), lodash → native JS or lodash-es
- Externalize: Load React/etc from CDN if multiple apps share it
- Dynamic imports: Heavy components (charts, editors) loaded on demand
- Check duplicates: Multiple versions of same package in bundle
🏛️ System Design & Architecture Questions
How would you design a scalable component library?
▼- Atomic Design: Atoms (Button) → Molecules (FormField) → Organisms (LoginForm) → Templates → Pages
- Design tokens: CSS variables for colors, spacing, typography — single source of truth
- TypeScript: Strong props typing, exported types for consumers
- Accessibility: ARIA built-in, keyboard navigation by default
- Storybook: Document and visually test every component
- Testing: 100% unit test coverage for shared components
- Versioning: Semantic versioning, CHANGELOG, publish to private npm registry
- Tree-shakeable: ES module exports so consumers only bundle what they use
How would you architect a large-scale React enterprise application?
▼- Feature-based structure: Each domain is a self-contained module
- Micro-frontends (for very large teams): Module Federation to split app into independently deployable pieces
- State layers: React Query for server state, Redux for complex client state, Context for auth/theme
- API layer: Central Axios instance with interceptors, typed service functions per domain
- Strict TypeScript: Strict mode, no
any, generated types from OpenAPI - Performance budget: Bundle size limits in CI, Lighthouse checks, Web Vitals monitoring
- Error handling: Error boundaries per route, global error interceptor, Sentry logging
- Design system: Shared component library with Storybook
🤝 Behavioral Questions
Tell me about a performance optimization you made. What was the impact?
▼Structure your answer using STAR (Situation, Task, Action, Result):
- Situation: Describe the app and the problem (e.g., "Dashboard with 1000-row table was freezing the browser on scroll")
- Task: What was your responsibility
- Action: Specific steps — profiled with React DevTools, identified re-renders, implemented react-window for virtualization, memoized expensive selectors with createSelector
- Result: Quantify — "reduced scroll lag from 300ms to under 16ms, LCP improved from 4.2s to 1.8s, bundle size reduced 40%"
How do you handle disagreements during code review?
▼- Separate the person from the code — critique the code, not the developer
- Ask questions instead of demanding changes: "I'm curious why you chose X over Y — is there a reason I'm missing?"
- Back objections with data or documentation (performance numbers, MDN docs, official guidelines)
- Know when to defer — not every battle is worth fighting; note it as tech debt
- Escalate via team discussion for architectural disagreements, not 1:1 PR comments
✅ Final Interview Checklist
- React hooks — useState, useEffect, useCallback, useMemo, useRef, useReducer, custom hooks
- TypeScript — generics, utility types, discriminated unions, type narrowing
- Vite config — build, proxy, SSL, aliases, manualChunks
- Webpack — entry, output, loaders, plugins, splitChunks, contenthash
- .npmrc — registry, SSL cafile, proxy, auth token
- ESLint + typescript-eslint config (TSLint is deprecated)
- Redux Toolkit — createSlice, createAsyncThunk, createSelector
- Context API — when to use, performance pitfalls
- React Query — useQuery, useMutation, query invalidation, optimistic updates
- Performance — code splitting, Web Vitals, virtualization, profiling
- Testing — RTL philosophy, getBy vs queryBy vs findBy, MSW for API mocking
- Accessibility — WCAG, ARIA, focus management
- CI/CD — GitHub Actions pipeline structure, npm ci vs npm install
- Docker — multi-stage builds, nginx SPA config
- Architecture — feature-based structure, design patterns, SOLID
- Code review — what to look for, common bugs, stale closures
🚨 Production Scenarios & Architecture Decisions
Senior and staff interviews are less about facts and more about how you think when things break in production or a hard architecture trade-off appears. Practise with realistic scenarios and structured model answers.
🧠 How to Structure Any Scenario Answer
What is the best structure for a scenario or incident answer?
▼Interviewers want to hear how you think, not just the final fix. Ask 1–2 clarifying questions first (scope, severity, who's impacted), then run this loop — and say its name so they can follow you:
- Confirm impact & scope — all users or a cohort? one route? which browsers/regions? Is it urgent (stop the bleeding) or can you investigate calmly?
- Reproduce & observe — never guess. Get the real error/log/metric/stack from production (RUM, error tracker, gateway logs, correlation id).
- Isolate — split hypotheses: client vs server vs infra vs data. Compare prod vs staging. Bisect changes/deploys.
- Fix the smallest thing — behind a feature flag or canary; verify the metric moved.
- Prevent — monitoring/alert, test, postmortem, process change so it can't recur silently.
For architecture questions, the same discipline applies: name the trade-offs, make a decision (not a menu), justify it against this specific product/team, and state what would make you change it.
🔁 Escalation: “The incident is live and a VP wants a status update in 10 minutes — you have verified nothing yet. What do you say, and what do you refuse to do?” A 10+ yr answer talks in business impact and blast radius, states what is being done to limit exposure, and refuses to invent a root cause or promise a fix time it cannot honour.
🔥 Production Incidents — Debug Under Pressure
A new feature works in staging, but users report a blank white screen only in production. How do you investigate?
▼Start by confirming the blast radius — whole app or one route? one browser/CDN region/cohort? — then compare what differs between production and staging.
- Prod-only differences to check: minified & mangled bundles (turn on source maps in your error tracker), a different API base URL / CORS, CSP headers only in prod, HTTPS mixed content, and a stale service worker.
- Classic cause — failed code-split chunk: the user is holding an old
index.htmlthat references a hashed lazy chunk the latest deploy removed, so the dynamicimport()404s and nothing renders. Confirm in the Network tab. - Fix pattern: a global Error Boundary + reload-on-chunk-failure handler that retries once and then hard-reloads; use
immutable, content-hashed assets. - See the real error: capture
window.onerror/unhandledrejectionand log to Sentry instead of a silent white screen — a blank page is a UX failure hiding a root cause. - Watch dev-only masking: StrictMode double-invoking effects can hide cleanup bugs that only surface in a real prod mount cycle.
Prevention: prod-parity staging (same CSP/env), source maps to the tracker, hashed assets, and a smoke test against the deployed bundle.
🔁 Escalation: “Now rule out the chunk-failure theory — it is only iOS Safari users on your two oldest supported versions. Walk me to the next hypothesis without a Network tab.” A deep answer bisects by cohort and feature flag, checks unsupported APIs/polyfills, and names the exact prod-only difference instead of restating a checklist.
One route is slow in production for a subset of users but fast in staging. Walk me through debugging it.
▼Staging being fast is a clue: it's often data-dependent (staging has small data) rather than purely an environment issue. Frame it as two hypotheses — infra/env vs large data cohort — then measure.
- Measure first: RUM/Web Vitals split by route, country, plan/tenant, device. Is it p50 or p95? A slow p95 usually means one big-data cohort, not everyone.
- Reproduce with real data: DevTools network + performance on an affected account. Check payload size (giant JSON, un-optimized images), an N+1 waterfall of tiny requests, and long main-thread tasks.
- Find which leg is slow: Server Timing headers / gateway logs tell you if it's the API (backend/DB) or the render (client). Look for unindexed queries or filters that only big tenants hit.
- Typical fixes: paginate + virtualize the list, filter/sort server-side instead of shipping 50k rows, trim/compress payloads, defer third-party scripts, warm caches.
- Confirm the cohort: cold CDN edge region? A feature-flag group? Roll the fix out behind a flag and compare the metric before declaring victory.
🔁 Escalation: “The affected cohort turns out to be exactly your enterprise tenants behind a corporate proxy/VPN. Re-run your reasoning.” A senior answer suspects proxy caching, stale TLS/MTLS, response re-download, or blocked/compressed assets and proves it from headers plus a repro on that network — it does not just say “paginate.”
APIs return intermittent 502/503 errors during busy hours — some users succeed, most fail then recover. What is your plan?
▼First, read the status code honestly: 500 is an app exception, 502 is a bad gateway/upstream, 503 is overload or maintenance. That immediately narrows the search.
- Correlate before concluding: gateway and API p99 vs traffic and deploy timeline. Is it during rolling deploys (instances restarting under traffic)? DB connection-pool exhaustion? Missing autoscaling under a spike?
- Look for the classic cascade: one external call with no timeout piles requests onto dying connections; a single bad tenant hammers the pool; GC/memory pressure on instances; DNS or load-balancer config.
- Add resilience: client retry with exponential backoff + jitter for transient 5xx; a circuit breaker so a failing dependency degrades (cached/stale data) instead of cascading; timeouts everywhere; idempotency keys so retries are safe.
- Cut the load: cache hot GETs at the CDN/edge, rate-limit per user, autoscale with proper connection-pool sizing, push heavy work to a queue.
- Operate safely: stagger deploys, only route traffic to an instance after its health check passes, and alert on p99 crossing a threshold — not just the 5xx counter, which spikes too late.
🔁 Escalation: “Load-balancer metrics are clean, yet 503s keep reaching specific regions and networks. Where is the discrepancy, and how do you confirm it?” Expect client- and network-side causes (corporate proxy caching a stale 503, DNS failover, CDN edge, mobile carrier, provider SLO) plus a proof method — not “autoscale more.”
A dashboard with live updates gets progressively slower and crashes the browser tab after ~an hour. How do you approach it?
▼This smells like a monotonic memory leak — something accumulates per update and never gets released. Prove it before fixing.
- Reproduce & measure: DevTools Memory — heap snapshot before/after an action, then diff retained objects; watch
performance.memoryover time. - Classic causes: listeners/subscriptions added in effects without cleanup (WebSocket,
setInterval, ResizeObserver/IntersectionObserver), closures that retain large arrays, an unbounded in-memory event history (append-only logs/ticks), or detached DOM nodes from key churn. - Fix: every effect returns its cleanup; cap buffers (keep last N events or a rolling time window); virtualize or paginate instead of holding every row; unsubscribe on unmount.
- Validate: disconnect/reconnect cycles must return the heap to baseline; add an automated soak test so it can't regress silently.
🔁 Escalation: “The heap stays flat and GC is healthy, but the tab still freezes every few minutes. What resource are you missing?” A deep answer moves past memory to event-loop starvation from a high-frequency socket/microtask handler, layout thrash from observer callbacks, GPU/WebGL or canvas growth — and confirms with the Performance panel’s long tasks, not heap snapshots.
A user's slow request overwrites newer server data with stale data, and two open tabs make it worse. How do you fix it?
▼- Kill stale in-flight requests:
AbortControlleron unmount or on a newer request; make state updates "latest-wins" by ignoring any response older than the current request token. - Make the server authoritative: send
If-Match/ a version (etagorupdatedAt) on writes; a 409 conflict should surface a "someone edited this — reload or merge" UI instead of silently overwriting. - Optimistic UI done right: roll back on failure and reconcile with the server's returned record on success — never trust the stale local guess to persist.
- Prevent double submits: disable the button while
pending, and use an idempotency key so retries can't create duplicates. - Multi-tab: sync via
BroadcastChannel+storageevents with a last-writer timestamp so both tabs converge. - Test it: throttle the network in DevTools and hammer save/refresh to reproduce ordering bugs deterministically.
🔁 Escalation: “Now two users edit the same record at once. Where does last-write-wins break, and what would you actually ship?” Distinguish field- and patch-level conflicts, server-side version checks, and offline queues — and only reach for OT/CRDT for genuinely concurrent editing. A 10+ yr person says which one, and why not the fancy option.
You just deployed and the error rate spiked. Walk me through your decision.
▼Stop the bleeding first, diagnose second. Choose roll forward vs roll back based on blast radius and whether the database already migrated.
- A feature flag / kill switch is the fastest lever — disable the risky behavior with no redeploy. Ship significant releases behind flags so this option always exists.
- Static SPA rollback is cheap: repoint the CDN to the previous immutable release. Backend is harder once a schema migration has run — prefer backward-compatible API changes and deploy data changes separately.
- If it isn't flaggable: roll back via blue/green or canary, confirm metrics recover, then postmortem. Re-deploying the same broken code "after a retry" is not a fix.
- Prevention: canary with % rollout + automated smoke/E2E against the new build in CI, an error-budget gate, prod-parity staging, and forward-only, reversible migrations.
🔁 Escalation: “Your app rollback succeeded, but the database schema cannot roll back and the new rows are incompatible with the old code. Walk me through recovery.” Expect forward-only compatible migrations, data vs code deploys kept separate, feature flags over rollback, and a data-repair path — engineered before the next deploy.
A token or API key leaked in client-side logs, or you found an XSS. What do you do?
▼- Stop the bleeding: rotate/revoke the token now, invalidate sessions, block the source if you can, and purge the value from logs/history and alerting stores.
- Scope: what can this credential access? Which users/records were exposed during the window? Assume the worst and audit.
- Eradicate: fix the XSS vector (escape output, never
innerHTMLwith untrusted input), add a strict CSP to blunt future injection, force re-login and rotate refresh tokens. - Prevent recurrence: secrets live server-side (or behind a backend proxy) — never in the client bundle or logs; redact sensitive fields in Sentry/logging by default; short-lived access tokens with refresh; least-privilege keys; secret scanning in CI.
- Communicate calmly, factually, with a timeline — no blame.
🔁 Escalation: “It is 3 a.m., the leaked credential is an admin token that can read all user data, and rotating it means taking a service down. Decide now.” Senior behaviour is risk-based and calm: weigh exposure vs availability, revoke or isolate when PII is at scale, communicate with stakeholders, and follow a runbook — not a menu of options.
Users are being double-charged when they retry a checkout. How do you fix and prevent it?
▼- Idempotency key: generate one stable key per checkout attempt (client-side, or better: server order id). Every retry reuses it; the payment provider and your backend dedupe on it and return the original result instead of charging again.
- Treat "request failed" ≠ "payment failed": never let the user retry from an ambiguous state. Poll the order API — the server is the source of truth — before offering a retry.
- UI: a real
processingstate that disables the pay button and explains the payment is being confirmed. - Backend guard: a unique constraint on the idempotency key so two concurrent duplicates cannot both commit; charge only after order/stock locking succeeds.
- Reconcile: payment webhooks are the final source of truth — match webhook to order state and auto-refund true duplicates.
🔁 Escalation: “The provider honours your idempotency key per merchant, but your order service restarts between ‘charge ok’ and ‘order committed.’ Design around that crash.” Expect a DB transaction binding the order to the payment reference (outbox pattern), replaying the idempotent result after restart, then webhook reconciliation — never re-charge on ambiguity.
🏗️ Architecture Decisions — Trade-offs & Reasoning
You're building a large new product for a growing team. Would you start with micro-frontends? When would you split?
▼Short answer: no — start with a well-structured monolith/monorepo and split only when you have evidence of pain. Micro-frontends trade runtime and ops complexity for org independence.
- Split signals: several product teams colliding in one repo, conflicting release cadences (one team can't wait for another to merge), independent deploys needed, or performance isolation requirements.
- Split progressively: extract a shared design system and core first, then split along true team boundaries with clear ownership — not every 5-line slice of the codebase.
- If you do split, define contracts first: the integration mechanism (module federation/iframes), routing ownership, cross-app communication via events rather than tight coupling, and a single design-system source for UI.
- Budget the real costs: version drift, duplicate dependencies, cross-app integration testing, shared auth/session, and observability that must span apps.
🔁 Escalation: “You did split, and a year later two apps have different React versions, duplicated auth, and design-system drift. Who owns integration risk, and what governance do you put in place?” A staff answer names a platform/contract team, a shared-shell and dependency policy, cross-app contract tests, and admission/exit criteria — architecture as an ongoing cost, not a one-time diagram.
How do you choose state management for a large enterprise React app?
▼- Separate server state from client state. Server data (profiles, orders) belongs in a data-fetching/caching layer (TanStack Query/SWR) — caching, invalidation, retries, loading and error states for free. Don't duplicate fetched data into Redux.
- Keep client state local with
useState/reducers where possible; lift only when genuinely shared. Truly cross-cutting global state (auth/session, theme, feature flags) is what Context is for. - For complex shared logic (carts, multi-step forms, undo, workflows) reach for Redux Toolkit or Zustand — pick on team familiarity and devtools needs; Zustand is lighter and more selector-friendly.
- URL is state: filters, pagination, and search belong in the URL for shareability and back-button support.
- Decide from actual needs — how much sharing, how often it changes, cache invalidation, team size — not fashion.
🔁 Escalation: “Now add live price changes pushed over a socket into that cart shared across three feature modules. Who owns the state, who updates it, and how do the modules stay consistent?” Expect one owner and one source of truth for the cart domain, the socket handler writing to that store (not per module), derived selectors in each module, and idempotent events.
A product team wants a fast mobile dashboard, but your API is a coarse REST service. REST, GraphQL, or a BFF?
▼- Start REST: simplest to cache (HTTP/CDN), version, and debug. Add purpose-built endpoints as over-fetching bites.
- BFF (backend-for-frontend): a per-client aggregation layer that calls downstream services and returns exactly the shape each client needs. Great middle ground — client simplicity and a typed contract without server-wide GraphQL complexity.
- GraphQL pays off when clients need many view-specific queries over related data and iterate fast on fields. Costs: HTTP caching is harder, you need a client cache (Apollo), plus depth/cost limiting, per-field authz, and schema governance.
- Recommendation pattern: REST + a thin BFF for most teams; adopt GraphQL only when a specific team's iteration speed demands it and you can staff it properly.
🔁 Escalation: “The BFF becomes the bottleneck and a second client needs the same data in a different shape. Evolve the BFF or finally adopt GraphQL — and what would make you change your mind?” A senior states the decision criteria and the real cost of each (schema governance, caching, staffing, versioning) and then commits to one.
You need live updates on a dashboard and some chat-like features. WebSockets vs SSE vs polling?
▼- Polling: simplest and stateless; fine when "updated within 30–60s" is acceptable; wasteful otherwise. Long-polling is only an HTTP fallback.
- SSE (Server-Sent Events): one-way server→client over HTTP with auto-reconnect and event IDs; plays nicely with proxies/CDNs. Ideal for feeds, notifications, price ticks — and it's all you need for most "live dashboard" features.
- WebSocket: full duplex — chat, collaborative editing, cursors, games. Costs: connection lifecycle, heartbeat/reconnect logic, and horizontal scale because a socket sticks to one instance (use a broker like Redis pub/sub for fan-out).
- Rule of thumb: prefer SSE/polling until you genuinely need client→server messages in real time. Design the UI to tolerate missed/stale messages (idempotent, versioned events) and add backpressure so your send queue can't grow unbounded.
- Choose by latency need and direction — not by trend.
🔁 Escalation: “Now it is a chat product: 100k concurrent connections across 30 instances. Walk through affinity, heartbeats, fan-out, ordering, and one instance dying.” Expect a broker/pub-sub, reconnect rebalancing, per-connection ordering with message ids, replay from the last ack, backpressure — and when you would buy (Pusher/SignalR) instead of build.
Architect a data-heavy grid (tens of thousands of rows) with filtering, sorting, and export.
▼- Never render all rows: virtualize the visible window (TanStack Virtual / react-window) so the DOM only holds visible rows.
- Decide server vs client filtering: over a few thousand rows or on a server dataset, do filtering/sorting/pagination server-side via a debounced API — the backend indexes do the work and payload/memory stay bounded. Client-side only for small, static data.
- Keep it predictable: stable row keys and a col-def/row-model abstraction; put sort and filter state in the URL; design loading/empty/error skeletons.
- Export: stream it from the server — big exports must never round-trip through the UI's DOM or main thread.
- Perf details: memoize row renderers, avoid re-rendering every row when one selection changes, and batch cell updates.
🔁 Escalation: “Users select 50k rows, sort by an unindexed computed column, and the API caps page size. Design the data path end to end.” Expect a server-side selection/session for the id set, batch action endpoints, computed sort pushed to the backend, selection state decoupled from rendered rows, and a streaming export.
How do you design error handling and resilience so one failure doesn't take down the whole app?
▼- Error boundaries per route/widget: one crashing component shows a scoped fallback, not a blank page.
- API error taxonomy: a typed
ApiErrorwith a code and aretryableflag; the UI maps codes to actions (401 → re-login, 403 → no permission, 409 → conflict UI, 5xx → retry). - Retry with exponential backoff + jitter for transient failures; idempotency keys make retries safe; a circuit breaker around third-party calls so a slow dependency degrades to cached/stale data instead of cascading.
- Observability: log to Sentry with context, and thread a correlation id through every request so support can trace a single failure.
- Offline resilience: queue mutations in IndexedDB and replay on reconnect; serve cached data while offline.
- Prove it: every UI state has a story — loading, empty, error, partial — with working retry buttons.
🔁 Escalation: “Your retry-with-backoff just amplified an outage into a retry storm that finished the database. What would you have done differently?” Expect jitter, a retry ceiling, a circuit breaker placed before the retries, bulkheading/semaphores, bounded queues, monitoring of retry volume, and an off switch — plus an honest postmortem.
Design authentication for a web app: token storage, refresh, and SSO.
▼- Use OIDC/OAuth 2.1 with PKCE against an identity provider rather than hand-rolling passwords. The backend enforces authorization; the frontend only drives the UX.
- Storage: keep the access token in memory where practical; put the refresh token in an
httpOnlySecureSameSitecookie so XSS can't read it. Long-lived secrets inlocalStorageare readable by any injected script — avoid. - Silent refresh: an axios/fetch interceptor refreshes before expiry and replays the single queued request once; on refresh failure redirect to login and preserve the return URL.
- JWT vs server session: JWTs are stateless but hard to revoke; if revocability matters more, an opaque server session is simpler. Trade this off deliberately.
- UI guards are UX, not security: route guards + layout-level checks control what a user sees, but the server re-authorizes every call anyway.
- Plan for threats: CSRF (SameSite/CSRF token for cookie flows), refresh-token rotation/reuse detection, concurrent sessions, and logout-everywhere.
🔁 Escalation: “Now mobile needs the same API and security says no refresh token on the device. Redesign for web + mobile + SSO.” Expect a web BFF with the refresh token in an httpOnly cookie, mobile using native OAuth PKCE with a short-lived token in secure storage plus rotation and device binding, and logout-everywhere.
You inherit a 10-year-old jQuery/Backbone app that must adopt React incrementally — no rewrite freeze allowed. What's the strategy?
▼- Strangler-fig, not big-bang: keep the old app running and mount React islands incrementally in the same page/DOM.
- Build an integration layer: a wrapper that mounts React inside legacy containers; share routing, auth, and state via a small event bus/pub-sub so the two worlds stay coherent.
- Ship vertical slices: migrate one user journey (checkout, search, settings) end-to-end — visible value and a real milestone — instead of rewriting isolated components.
- Protect the journey: preserve deep links, analytics, and error handling; contract-test the API surface both the old and new code consume.
- Do the runway work first: a monorepo + Vite for the new parts, a build that can host both worlds, then retire legacy as coverage grows. Never stop shipping features during migration.
- Define an exit: explicit criteria — X% of traffic on the new code, legacy bundles deleted — so the migration has an end, not a perpetual middle.
🔁 Escalation: “Six months in, legacy is still 60% of traffic and two teams maintain both codebases. How do you accelerate, and when do you kill the old app?” Expect stopping net-new features on legacy, a team-topology change, shared infra, explicit sunset criteria with executive sign-off — a plan that shrinks parallel-maintenance cost instead of extending it.
🌍 Production at 2M+ Users — Architecture, Design Systems & Buttery UX
What changes when your app serves 2M+ active users: the full consideration matrix, a reference architecture, a complete design system, and how to engineer the smoothest possible user interactions at scale.
🧮 What Changes at 2M+ Active Users — The Consideration Matrix
Interviews test whether you think in systems, not components. At 2M+ MAU every naive choice becomes an incident. Walk through this matrix — it is the mental checklist for any "scale" question:
| Area | What breaks at scale | What you must design for |
|---|---|---|
| Performance budgets | One heavy bundle × 2M users = a slow product for everyone, everywhere | Hard budgets: JS ≤ 170KB gzip initial, LCP ≤ 2.5s (p75), INP ≤ 200ms (p75), CLS ≤ 0.1. Enforced in CI, not aspirational |
| Core Web Vitals / INP | Field metrics degrade silently as features ship | RUM (Real User Monitoring) on every release + regression alerts. INP is the 2026 headline metric — every interaction budgeted |
| Bundle & delivery | Route-level code splitting missing → megabytes shipped to every user | Code-split per route + per heavy interaction; tree-shaking; import maps; differential serving (ES2022); CDN with edge caching of hashed assets |
| Rendering strategy | Pure CSR = blank screen + 3 waterfalls; pure SSR = slow TTFB under load | Choose per route: SSG for marketing, ISR for content, SSR + streaming for dynamic, islands/partial hydration where it pays. Never one strategy for the whole app |
| Data & API layer | Chatty REST + N+1 requests → waterfalls; server load explodes | BFF or GraphQL aggregation, cursor pagination, query batching, HTTP caching headers, stale-while-revalidate, streaming responses |
| State management | Global store becomes a god-object; renders explode | Server state in a cache layer (TanStack Query / SWR); client state small and local; URL as state for shareable views; selector discipline + memoization |
| Observability | 2M users hit a bug you cannot see | Error tracking (source-mapped), RUM, tracing across client→BFF→service, structured logging, SLOs with error budgets |
| Reliability | Third-party APIs and your own services fail daily at this scale | Graceful degradation everywhere: fallback UIs, retry with backoff, circuit breakers client-side, offline support via service worker |
| Security | XSS, token theft, and data exposure scale with audience | CSP with nonces, sanitization at the boundary, httpOnly cookies / short-lived tokens, no secrets in the bundle, audit third-party scripts |
| Accessibility & i18n | 2M users = every disability and every locale represented | WCAG 2.2 AA as a release gate (not a polish item); keyboard + screen-reader tested journeys; i18n/l10n from day one (ICU messages, RTL-ready layout) |
| Experimentation | You can no longer guess — changes affect millions | Feature flags with remote config; A/B testing infra; gradual rollouts (1% → 100%) with automatic rollback |
| Cost | Every unused byte × 2M users × 365 days = real money | Budget bytes, cache aggressively, compress images/video, consider edge rendering to cut origin load |
| Team topology | One giant frontend repo = merge hell and no ownership | Monorepo with clear package ownership (Nx/Turborepo); design system as an internal package; feature teams own vertical slices |
Interview framing: "2M+ users" is a proxy question. Interviewers want to hear you translate scale into concrete engineering constraints — budgets, strategies, observability, and degradation — not buzzwords. Name the metric, the mechanism, and the trade-off for every claim.
🏗️ Reference Architecture for a 2M+ User Frontend
┌──────────────────────── 2M+ USERS (global) ────────────────────────┐
│ │
│ Browser / PWA (React, route-split, SW-cached shell) │
│ │ HTTPS (h2/h3, TLS, preconnect) │
│ ▼ │
│ CDN / Edge (Cloudflare Fastly) │
│ • static assets, immutable hashed files (cache: 1y) │
│ • HTML at the edge (ISR revalidation, personalization cookies) │
│ • edge redirects/A-B splits, bot filtering, WAF │
│ │ │
│ ▼ │
│ Web BFF (Node, per-region) ←— single origin for the FE │
│ • aggregation & shape-for-UI, auth tokens (httpOnly), session │
│ • SSR / streaming render for dynamic routes │
│ │ │ │
│ ▼ ▼ │
│ API Gateway Legacy/3rd-party APIs (cached, circuit-broken) │
│ │ │
│ ▼ │
│ Microservices (auth, catalog, feed, orders…) │
│ │ │
│ ▼ │
│ Data: Postgres / Redis (cache, queues) / Object storage │
└─────────────────────────────────────────────────────────────────────┘
Why this shape:
- One BFF, not per-page calls. The browser talks to exactly one origin that already has the user's identity and can aggregate — kills waterfalls and centralizes auth.
- The edge absorbs the herd. 90% of requests should be answered by CDN cache or edge logic; the origin only does the work that must be fresh and personal.
- Streaming everywhere it counts. TTFB of the first paint chunk is what users feel — stream the shell, then the content, then the deferred islands.
- Everything degrades. Every service call has a fallback (stale cache → optimistic UI → error state with retry). The page is never blank.
🎨 The Complete Design System — the Scale Multiplier
A design system is what lets 20 feature teams ship one coherent product to 2M users without 20 visual dialects. A complete one has five layers:
| Layer | Contents | Scale rule |
|---|---|---|
| 1. Tokens | Color, typography, spacing, radius, shadow, motion — as primitives (raw) + semantic (intent) tokens | Components reference semantic tokens only; themes swap token values, never component code |
| 2. Components | Accessible primitives: Button, Input, Dialog, Select, Table… with variants | One implementation per component, consumed everywhere; props over forks; no per-team copies |
| 3. Layout & patterns | Spacing scale, grid, page templates, empty/loading/error states | State patterns shared: skeleton, empty, error, offline — consistent across every feature |
| 4. Icon & asset system | One icon set (SVG, stroke-based), image guidelines | Icons as a package with strict naming; tree-shaken on import |
| 5. Governance & docs | Storybook catalog, usage docs, contribution RFC, versioning, codemods | Semantic versioning + deprecation policy; breaking changes ship with automated codemods |
Tokens — the part interviewers probe deepest:
/* Primitives — raw scale, theme-agnostic */
:root {
--color-gray-50: #f9fafb; --color-gray-900: #111827;
--color-brand-500: #4f46e5;
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--radius-sm: 6px; --radius-md: 10px;
--font-sans: 'Inter', system-ui, sans-serif;
}
/* Semantic tokens — components ONLY use these */
:root[data-theme='light'] {
--bg-surface: #ffffff; --bg-canvas: #f3f4f6;
--text-primary: var(--color-gray-900);
--text-secondary: #4b5563;
--border-default: #e5e7eb;
--action-primary: var(--color-brand-500);
--shadow-card: 0 1px 3px rgb(0 0 0 / 0.08);
}
:root[data-theme='dark'] {
--bg-surface: #1f2937; --bg-canvas: #111827;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--border-default: #374151;
--action-primary: #6366f1;
}
/* One component, both themes, zero media queries inside components */
.card {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-default);
padding: var(--space-4);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
}
Design system performance rules: ① Emit static CSS (or CSS variables) — avoid runtime CSS-in-JS at scale; ② tree-shake — import only used components/icons; ③ tokens as CSS custom properties cost ~zero at runtime; ④ motion tokens respect prefers-reduced-motion globally; ⑤ every component ships with keyboard + screen-reader behavior by default, not as an opt-in.
Interview answer shape: "Tokens first → semantic tokens → components reference tokens → themes swap tokens → a11y baked in → Storybook + versioning + codemods for governance." That arc shows you understand the design system as product infrastructure, not a component folder.
🖱️ Engineering the Smoothest Interactions at Scale
Smoothness at 2M+ users is not a feeling — it is a budget you engineer to. The 2026 metric is INP (Interaction to Next Paint), and the rules of thumb are:
| Interaction type | Budget | How to hit it |
|---|---|---|
| Typing / key press | < 50ms | No work on the input path: controlled-input updates only, defer validation, avoid re-rendering heavy trees per keystroke |
| Click / tap | < 100ms | Optimistic UI first, network second; event delegation; no layout thrash in handlers |
| Visual feedback | < 200ms (INP target) | Skeleton/optimistic state immediately; view transition; never a spinner that appears late |
| Navigation | LCP ≤ 2.5s, instant on repeat | Route prefetch on hover/intent, SW cache, streaming, View Transitions API for continuity |
The main-thread discipline (where jank actually comes from):
- Virtualize long lists — 50k rows is 50k DOM nodes until you window them (react-window / TanStack Virtual). Render only the viewport ± overscan.
- Never layout-thrash — batch reads and writes; avoid forcing sync layout in rAF loops; use
content-visibility: autofor off-screen sections. - Keep the main thread free — move parsing/transform work to workers; chunk long tasks (< 50ms) with
scheduler/yielding; lazy-load anything not needed for the first interaction. - Optimistic everything — update the UI with the expected result instantly; reconcile on the server response; roll back cleanly on failure (the rollback is what users remember).
- Perceived performance beats actual — skeleton before data, instant navigation overlay, cached shell (service worker) so repeat visits paint from cache while the network refreshes in the background.
// Optimistic mutation — the pattern for buttery interactions
const { mutate } = useMutation({
mutationFn: (v) => api.post('/likes', v),
onMutate: async (vars) => {
await queryClient.cancelQueries(['post', vars.id]);
const prev = queryClient.getQueryData(['post', vars.id]);
// Apply the expected result IMMEDIATELY — no waiting on the network
queryClient.setQueryData(['post', vars.id], (old) =>
({ ...old, likes: old.likes + 1, liked: true }));
return { prev }; // snapshot for rollback
},
onError: (_e, vars, ctx) => {
queryClient.setQueryData(['post', vars.id], ctx.prev); // clean rollback
},
});
// Virtualize 50k rows — render ~12, not 50,000
const rowVirtualizer = useVirtualizer({
count: 50000,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 8,
});
// <div ref={parentRef}><div style={{ height: rowVirtualizer.getTotalSize() }}>
// {rowVirtualizer.getVirtualItems().map(item => /* render item */)}
// </div></div>
The mental model interviewers reward: smoothness = main thread time per interaction + perceived latency. Name both: "I keep interactions under 100ms of main-thread work by virtualizing, memoizing, and deferring; and I make the network invisible with optimistic UI, prefetching, and a cached shell."
🎯 Scale & UX Interview Q&A
Design the frontend architecture for an app going from 10K to 2M+ MAU. Where do you start, and what breaks along the way?
▼- Start with measurement, not guesses: put RUM in place first — Core Web Vitals + INP + error tracking — so every later decision is evidence-driven.
- Hard budgets + code-splitting: route-level splitting from day one; a CI-enforced JS budget that the team cannot silently exceed.
- Rendering by route type: static marketing, ISR content, streaming SSR for personalized pages — never one strategy.
- BFF + edge: one browser origin, CDN absorbing static and cached traffic; origin only does fresh/personal work.
- Server-state discipline: a cache layer (TanStack Query/SWR) with stale-while-revalidate; optimistic mutations everywhere users act.
- Observability + rollout infra: feature flags, gradual rollouts, SLOs with error budgets — at 2M users you ship by measurement, not by hope.
🔁 Escalation: "But the backend team refuses a BFF and the API is chatty." Expect: client-side aggregation with batching where possible, GraphQL as a negotiation, or an API gateway owned by platform — plus a hard conversation about N+1 waterfalls measured in RUM.
Your INP went from 180ms to 450ms after the last release. Walk through the diagnosis and the fix.
▼- Confirm with field data: segment INP by interaction type (click vs key), by route, by device class, by browser — the regression usually lives in one slice.
- Reproduce with the Long Animation Frames API (LoAF): long tasks > 50ms are the INP killers; LoAF tells you exactly which task and which handler.
- Profile the hot path: DevTools performance trace on the worst interaction; look for layout thrash, synchronous re-render of a large tree, or a blocking third-party script.
- Fix at the right layer: memoize/isolate re-renders (React.memo, selector discipline,
useDeferredValuefor expensive derived state), defer non-critical work, break long tasks, or move work to a worker. - Guard the fix: add the worst interaction to a performance test (Playwright tracing or a Lighthouse CI check) so the regression cannot silently return.
🔁 Escalation: "The slow interaction is inside a third-party widget you cannot change." Expect: isolation (iframe or separate thread), lazy-mounting only when the user actually engages it, or replacing the vendor.
Design a design system for a company with 60 frontend engineers across 8 product teams. What is the operating model?
▼- Tokens as the contract: primitives → semantic tokens; themes (light/dark/brand) swap tokens only. Components never hardcode values.
- Accessibility baked in: keyboard, focus management, screen-reader semantics are part of every component's definition of done — not add-ons.
- Distribution as packages: design system is an internal npm package (or workspace) with its own CI, Storybook, and versioning.
- Governance with a path: an RFC process for new components; a deprecation policy; codemods for every breaking change so 8 teams upgrade without pain.
- Team model: a small core team (2–4) owns the system; feature teams contribute via the RFC process — avoid both "design system bus factor" and "nobody owns it."
- Adoption metrics: track % of product UI built from system components; make the system the path of least resistance.
🔁 Escalation: "Two teams fork Button because they need different behavior." Expect: variant/props review, composition over forking (compound components), and a hard rule that forks are a bug report, not a solution.
A dashboard page renders 50,000 rows of live data that updates every second. Make it smooth.
▼- Virtualize the list — render only visible rows ± overscan; 50k rows becomes ~15 DOM nodes (TanStack Virtual / react-window).
- Memoize row components by stable identity (row id as key); updates touch only changed rows.
- Batch incoming updates — coalesce the per-second stream (requestAnimationFrame or a buffer) so React renders once per frame, not 60 times.
- Isolate the data path — keep live updates in a store slice outside the row tree where possible; use
useSyncExternalStorefor tearing-free subscriptions. - Avoid re-render storms — selector discipline, stable callbacks, columns/sort state hoisted so a cell update does not re-render the grid chrome.
🔁 Escalation: "Now the grid also needs sticky columns, keyboard navigation, and cell-level editing." Expect: the grid as a compound component with its own virtualization and an interaction model — and honest scoping ("virtualization + editing are two systems; sequence them").
How do you deliver a personalized home page to 2M users with near-zero perceived latency?
▼- Split the page into tiers: a universally cacheable shell (header, nav, layout) served from the edge instantly; personalized regions (feed, recommendations) filled second.
- Stream the HTML — shell first, content streams in as the BFF aggregates; the user is reading before the slowest API answers.
- Cache the shell in the service worker — repeat visits paint from cache while the network refreshes (stale-while-revalidate); navigation feels instant.
- Prefetch on intent — hover/focus on the next destination triggers a fetch; by the click, the data is already there.
- Edge personalization where it fits — lightweight signals (geo, device, cookie-based segment) resolved at the edge; heavy personalization stays server-side and async.
🔁 Escalation: "Personalization requires a real-time recommendation call that takes 800ms." Expect: don't block the page on it — render the default tier, hydrate, then swap in the personalized tier (progressive enhancement of content).
🔄 Cross-Component State Sync — Every Section Always Fresh
How do you guarantee that every section of a page shows the latest value when another component on the same page updates it? Single source of truth, lifting state, context vs stores, external events, and the pitfalls that cause stale sections.
🧭 The One-Sentence Answer
Never keep two copies of the same value. One section "updates" and another section "reflects" the same piece of state — so store that state once, in a place both can read, and let React's one-way data flow push the new value into every section that renders it. Stale sections are almost always duplicated state: two components each hold their own copy, and nothing tells copy B that copy A changed.
The rule that prevents the whole class of bug: derive, don't duplicate. If a value can be computed from state you already have, compute it. If it must be shared, lift it. The moment you copy state into a second place "to keep it in sync," you have created the sync bug you were trying to avoid.
🪜 The Decision Ladder (choose the lightest tool that works)
| Scenario | Mechanism | Trade-off |
|---|---|---|
| Two sibling sections share one value | Lift state up to the nearest common parent; pass value + updater down as props | Canonical React. Verbose if the tree is deep (prop drilling) |
| Many sections across a page need it, tree is deep | Context with a memoized value + updater | All consumers re-render when the value changes; scope with selectors or split contexts |
| Page-wide state with frequent updates from many places | External store (Zustand / Redux Toolkit / Jotai) with per-component selectors | Subscribers update only when their slice changes — best re-render scoping |
| The value comes from the server (another user, another tab, polling) | Server-state cache (TanStack Query / SWR) — one query key, every section subscribes | Handles caching, invalidation, refetch; sections always read the cache, so they can't diverge |
| Pushed events: WebSocket ticks, SSE, postMessage, broadcast between tabs | External store + useSyncExternalStore, or a tiny event bus with subscription | React must be told about outside changes synchronously — this hook is the sanctioned bridge |
The interview narrative: "Start with the cheapest correct
answer — lift the state. Reach for context when drilling hurts, a
selector-based store when many sections update frequently, a server cache
when the value is remote, and useSyncExternalStore when the
source is outside React entirely."
🏗️ Pattern 1 — Lifting State (the default answer)
Two sections of a page, one value. The common ancestor owns the state; both sections receive it as props and re-render when it changes — the updater lives in one place, so the sections can never disagree:
// Dashboard = common ancestor. It owns the truth.
function Dashboard() {
const [selectedRegion, setSelectedRegion] = useState('APAC');
return (
<div className="dashboard">
{/* Section A: the writer — calls the shared updater */}
<FilterSection value={selectedRegion} onChange={setSelectedRegion} />
{/* Section B: a reader — always sees the latest value
because it renders from the SAME state object */}
<ChartSection region={selectedRegion} />
<TableSection region={selectedRegion} />
</div>
);
}
// Any section can be both: read `value`, write via `onChange`
function FilterSection({ value, onChange }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option>APAC</option><option>EMEA</option><option>US</option>
</select>
);
}Why this guarantees freshness: there is exactly one useState for the value. Section B cannot be stale because it has no state of its own to go stale — it re-renders whenever Dashboard re-renders with a new value. Add React.memo to sections only when the value prop genuinely didn't change.
🌳 Pattern 2 — Context for deep trees (without the re-render trap)
When the shared value must reach sections deep in the tree, context removes prop drilling — but the classic mistake is a context value object recreated every render, which re-renders every consumer. Two disciplines keep it fast:
// 1) Memoize the value object — identity is stable until state changes
function DashboardProvider({ children }) {
const [region, setRegion] = useState('APAC');
const value = useMemo(() => ({ region, setRegion }), [region]);
return <RegionContext.Provider value={value}>{children}</RegionContext.Provider>;
}
// 2) Split concerns: a "value" context for readers, an "API" context for
// writers — so components that only WRITE never re-render on value changes
const RegionValueContext = createContext('APAC'); // readers subscribe here
const RegionApiContext = createContext(null); // writers get setRegion here
function ChartSection() {
const region = useContext(RegionValueContext); // re-renders on change ✓
return <Chart data={fetch(region)} />;
}
function RegionPicker() {
const setRegion = useContext(RegionApiContext); // NEVER re-renders on change ✓
return <select onChange={(e) => setRegion(e.target.value)}>…</select>;
}📡 Pattern 3 — External updates (WebSockets, other tabs, worker events)
The hardest sync case: the value changes outside React — a tick
arrives on a WebSocket, another tab posts a message — and every section on
the page must update. The sanctioned bridge is useSyncExternalStore
(it replaced the old useMutableSource and handles tearing
correctly):
// A tiny external store fed by a WebSocket
const tickStore = {
listeners: new Set(),
lastTick: { price: 0, at: 0 },
subscribe(cb) {
tickStore.listeners.add(cb);
return () => tickStore.listeners.delete(cb); // ALWAYS return cleanup
},
getSnapshot() { return tickStore.lastTick; }, // must return cached ref!
};
ws.onmessage = (e) => {
tickStore.lastTick = JSON.parse(e.data); // replace ref, never mutate
tickStore.listeners.forEach((cb) => cb()); // notify React
};
// Every section that renders the price subscribes through this one hook —
// one source, N synced sections
function useLastTick() {
return useSyncExternalStore(tickStore.subscribe, tickStore.getSnapshot);
}
function PriceHeader() { const t = useLastTick(); return <h1>₹{t.price}</h1>; }
function TickerTape() { const t = useLastTick(); return <span>₹{t.price}</span>; }
function MiniChart() { const t = useLastTick(); /* … */ }Three rules that make external sync safe: ① never mutate the snapshot — replace it with a new reference so React can diff cheaply; ② always return the cleanup function from subscribe (forgetting it leaks listeners and stops updates); ③ if the event source is a store library (Zustand/Redux), they already implement this bridge — use their hooks instead of hand-rolling.
🕳️ The Pitfalls That Create Stale Sections
| Pitfall | Symptom | Fix |
|---|---|---|
| Duplicated state (each section holds its own copy) | Section B shows yesterday's value after A updates | Single source of truth; derive or lift |
| Stale closure in an effect or callback | Handler reads an old value even though state changed | Correct deps array; functional updates setCount(c => c + 1); refs for values the effect must always see |
| Context value recreated each render | Every consumer re-renders on ANY provider render | useMemo the value; split value/API contexts |
Effect "syncs" state between components (useEffect + setState in a loop) | Flicker, extra renders, occasional loops | Sync during render via derivation or useSyncExternalStore — never an effect |
| Missing subscription cleanup | Updates stop after navigation, or fire N times | Return unsubscribe from subscribe / effect cleanup |
| Mutating store state in place | getSnapshot returns the same ref — React sees "no change," sections freeze | Always replace the reference on update |
| Whole-page re-render from one keystroke | Every section re-renders per keystroke — jank, and (worse) unrelated sections get "fresh" but wasteful values | Scope state closer to the writer; memo sections; selectors |
Debugging a stale section, fastest path: ① is the value in two places? (search for the initial value in both sections) → ② does the reader subscribe to the SAME source the writer updates? → ③ React DevTools "Highlight updates" to see whether the reader even re-renders → ④ check the subscription cleanup. Nine times out of ten it is step ①.
🎯 State Sync Interview Q&A
A dashboard has a filter section and three data sections that must all reflect the filter instantly. How do you guarantee it?
▼- Lift the filter state into the dashboard (the common ancestor) — one
useState, passed down asvalue+onChange. - Sections become pure renderers of props — they cannot be stale because they hold no copy of the filter.
- Add a server-state cache (TanStack Query) keyed by the filter — when the filter changes, the query refetches and every section reading that key updates together.
- Scope re-renders: memo the sections so a keystroke in the filter doesn't re-render the whole dashboard.
🔁 Escalation: "The data arrives over WebSockets, not HTTP." Expect the useSyncExternalStore pattern: one external store fed by the socket, every section subscribing through one hook — with snapshot replacement and cleanup covered.
Why does Context cause every consumer to re-render, and how do you scope it?
▼- Mechanism: when a provider's
valueprop changes identity, React re-renders every component that callsuseContexton it — no memoization happens for you. - Scoping fix 1 — memoize the value:
useMemoso the object is stable unless real state changed. - Scoping fix 2 — split contexts: a value context for readers and an API context for writers; components that only write never re-render on value changes.
- Scoping fix 3 — split by domain: one context per slice of state, not one giant app context.
- When context is the wrong tool: high-frequency updates from many places — use a selector-based store (Zustand/Redux) where a subscriber re-renders only when its selected slice changes.
🔁 Escalation: "The value updates 60 times a second (a live chart) — can Context handle it?" Expect: a selector store or useSyncExternalStore, and rendering the high-frequency part in its own leaf component so 60 Hz updates don't re-render the page chrome.
Section A shows a counter. Section B increments it. After some clicks, Section A shows a stale number until you click again — where do you look first?
▼- First suspect: two counters. Section A has its own
useStateinitialized once ("to show the value") and never receives updates — the classic duplicated-state bug. Fix: single source of truth in the parent. - Second suspect: stale closure — B's increment handler captures an old count. Fix: functional update
setCount(c => c + 1)or correct deps. - Third suspect: subscription/cleanup if the count arrives via a store or socket — an uncleaned listener from a previous mount is updating a detached section.
- Fourth suspect: the reader isn't actually subscribed — it renders from a snapshot taken once (e.g., in a ref or captured prop).
🔁 Escalation: "It's fine in dev but stale in production." Expect: React StrictMode double-invoking effects in dev hiding a missing cleanup — subscriptions look correct in dev, leak/duplicate in prod.
Two browser tabs show the same document; edits in one must appear in the other instantly. How?
▼- BroadcastChannel for same-origin cross-tab messages: each tab posts document updates; each subscribes and applies them to its store.
- Storage events (
window.addEventListener('storage', …)) fire in OTHER tabs when localStorage changes — a simpler fallback that also survives a page reload. - Both feed the same external store —
useSyncExternalStorebridges the incoming events into React so every section in the receiving tab re-renders. - Server as the arbiter: the real source of truth stays server-side; BroadcastChannel is a latency shortcut, and a reload always reconciles from the server.
🔁 Escalation: "What about conflicts — two tabs editing the same field?" Expect: last-write-wins with a version field, or CRDT/operational-transform for true collaboration — and honest scoping ("for a doc editor, the server owns truth; tabs are mirrors").