Covers the gaps not fully addressed in the module guide.
Every section has: the question, the trap/gotcha, a real-world answer, and a code example.
- useEffect Deep Dive
- Custom Hook Patterns
- useLayoutEffect vs useEffect
- Strict Mode Behaviour
- Memory Leaks & Cleanup
- Stale Closures & Dependency Arrays
- Common "What's Wrong With This Code?" Traps
- System Design Questions
- Behavioural / STAR Questions
- Quick-Fire Q&A Cheatsheet
- Testing Patterns — RTL & Vitest
- TypeScript-Specific React Patterns
- React 18 — Automatic Batching & flushSync
- Next.js Interview Questions
- Implement From Scratch — Coding Challenges
Trap: Most candidates say "it runs after render". The real answer is more nuanced.
Real answer:
useEffect is a mechanism to synchronise a React component with an external system (DOM, network, subscription, timer). It is NOT a lifecycle replacement. React 18 recommends thinking of effects as "side-effect synchronisation", not "run after mount/update".
// ❌ Wrong mental model: "run on mount"
useEffect(() => {
fetchUser(userId)
}, [])
// ✅ Correct mental model: "sync with userId"
useEffect(() => {
fetchUser(userId)
}, [userId])
// If userId changes, re-sync. That's the intent.Trap: The second request may resolve before the first, showing stale data.
// ❌ Race condition — if userId changes rapidly, old responses can win
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(r => r.json())
.then(setUser)
}, [userId])
// ✅ AbortController — cancel the in-flight request on cleanup
useEffect(() => {
const controller = new AbortController()
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(setUser)
.catch(err => {
if (err.name === 'AbortError') return // intentional, ignore
setError(err)
})
return () => controller.abort() // cleanup cancels previous fetch
}, [userId])Real-world context: "In production at [company], we had a search box that fetched results on every keystroke. Users on slow connections saw results flash back to a previous query. We fixed it with AbortController and also added debouncing."
const [data, setData] = useState([])
useEffect(() => {
setData([1, 2, 3]) // triggers re-render → effect runs again → infinite loop
}, [data]) // ← data changes every render because [] !== []Answer: data is created fresh each render. React uses Object.is() for dependency comparison — two different array references are always "changed". Fix: either remove data from deps if you don't need to watch it, or use a ref/stable selector.
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000)
return () => {
clearInterval(id) // runs: 1) before re-running the effect, 2) on unmount
}
}, [])Key point: Cleanup runs before the next effect execution, not just on unmount. This is how React prevents memory leaks and stale subscriptions automatically.
import { useState, useEffect } from 'react'
function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// Usage
function SearchBox() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 400)
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery)
}, [debouncedQuery])
return <input value={query} onChange={e => setQuery(e.target.value)} />
}import { useRef, useEffect } from 'react'
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
}) // no deps array → runs after every render, capturing the last render's value
return ref.current // returns the value from the PREVIOUS render
}
// Usage: highlight what changed
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
return <p>{prevCount} → {count}</p>
}import { useEffect, useRef } from 'react'
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
element: EventTarget = window,
) {
const handlerRef = useRef(handler)
// Keep ref in sync without re-running the effect
useEffect(() => { handlerRef.current = handler }, [handler])
useEffect(() => {
const listener = (e: Event) => handlerRef.current(e as WindowEventMap[K])
element.addEventListener(event, listener)
return () => element.removeEventListener(event, listener)
}, [event, element])
}
// Usage
function App() {
useEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal()
})
}import { useEffect, useRef, useState } from 'react'
function useIntersectionObserver(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null)
const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
options,
)
observer.observe(el)
return () => observer.disconnect()
}, [options])
return { ref, isIntersecting }
}
// Usage — trigger load more when sentinel div enters viewport
function InfiniteList() {
const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.1 })
useEffect(() => {
if (isIntersecting) loadNextPage()
}, [isIntersecting])
return (
<ul>
{items.map(i => <li key={i.id}>{i.name}</li>)}
<div ref={ref} /> {/* sentinel */}
</ul>
)
}useEffect |
useLayoutEffect |
|
|---|---|---|
| When it fires | After paint (async) | After DOM mutation, before paint (sync) |
| Blocks browser paint? | No | Yes |
| Use for | Data fetching, subscriptions | DOM measurements, avoiding flicker |
// ❌ useEffect — causes visible flicker because paint happens first
useEffect(() => {
ref.current.style.height = ref.current.scrollHeight + 'px'
}, [])
// ✅ useLayoutEffect — DOM is mutated before the browser repaints
useLayoutEffect(() => {
ref.current.style.height = ref.current.scrollHeight + 'px'
}, [])
// Real-world: tooltip positioning, measuring DOM size, animation initial stateRule of thumb: Default to useEffect. Only switch to useLayoutEffect if you see a flash/flicker.
Answer: React 18 Strict Mode intentionally mounts → unmounts → remounts every component in dev to surface bugs in cleanup logic.
// This will log "mounted" TWICE in dev (once per mount cycle)
useEffect(() => {
console.log('mounted')
return () => console.log('cleanup')
}, [])
// ✅ This is not a bug — it's React verifying cleanup is correct.
// In production, it mounts once.
// ❌ Code that breaks in Strict Mode (missing cleanup):
useEffect(() => {
const socket = new WebSocket('wss://...')
// No cleanup → second mount creates a second socket
}, [])
// ✅ Fixed:
useEffect(() => {
const socket = new WebSocket('wss://...')
return () => socket.close()
}, [])What Strict Mode catches:
- Missing effect cleanup → memory leaks
- Side effects in render body (should be pure)
- Deprecated API usage
1. Setting state on unmounted components (pre-React 18)
// ❌ Classic pattern — setState after async op on unmounted component
useEffect(() => {
fetchData().then(data => setData(data)) // component may be gone by then
// In React 18+ this is a no-op warning, in older React it throws
}, [])
// ✅ Modern fix with AbortController (also prevents race conditions)
useEffect(() => {
const controller = new AbortController()
fetchData({ signal: controller.signal }).then(data => setData(data))
return () => controller.abort()
}, [])2. Forgotten event listeners
// ❌ Leak — adds a new listener on every render
useEffect(() => {
window.addEventListener('resize', handleResize)
// No return! Listeners stack up.
})
// ✅
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [handleResize])3. Uncleared timers / intervals
// ❌
useEffect(() => {
setInterval(() => ping(), 5000)
}, [])
// ✅
useEffect(() => {
const id = setInterval(() => ping(), 5000)
return () => clearInterval(id)
}, [])// ❌ Stale closure — count is captured as 0 at effect creation time
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // always logs 0, never the updated value
setCount(count + 1) // always sets to 1
}, 1000)
return () => clearInterval(id)
}, []) // ← empty deps = never re-subscribes
// ✅ Option 1: include count in deps (re-creates interval each tick)
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000)
return () => clearInterval(id)
}, [count])
// ✅ Option 2: use functional updater (doesn't need count from closure)
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000)
return () => clearInterval(id)
}, []) // safe! setCount(fn) always gets the latest stateReal-world context: "We had a WebSocket handler in a useEffect that referenced user permissions from a closure. When permissions updated, the handler still used the old snapshot. Fixed it by storing permissions in a ref and reading ref.current inside the handler."
Only when:
- It's a stable reference that never changes —
useStatesetter,useReducerdispatch,useRefobject - You intentionally want to capture the initial value only (document it with a comment)
- You're using a ref as the bridge (read
ref.currentinside the effect, update it in a separateuseEffect)
// Stable refs — safe to omit:
const [, dispatch] = useReducer(reducer, init)
const setCount = useState(0)[1]
const myRef = useRef(null) // the ref object is stable, .current is not
// Safe idiom: "stable handler via ref"
const onMessageRef = useRef(onMessage)
useEffect(() => { onMessageRef.current = onMessage }) // sync without dep
useEffect(() => {
socket.on('message', (d) => onMessageRef.current(d))
}, []) // socket is stable, handler is always current via ref// ❌ Index as key — React reuses components on reorder, state bleeds
{users.map((u, i) => <UserCard key={i} user={u} />)}
// ✅
{users.map(u => <UserCard key={u.id} user={u} />)}// ❌ New object created every render → child always re-renders
<ThemeProvider value={{ color: 'red', font: 'sans' }}>
// ✅ Memoize or move outside component
const theme = useMemo(() => ({ color: 'red', font: 'sans' }), [])
<ThemeProvider value={theme}>// ❌ Reading state immediately after setting it
setCount(count + 1)
console.log(count) // still the old value!
// ✅ Use the updater pattern or useEffect to react to the new value
setCount(c => c + 1)
// Or read the new value in a useEffect([count])// ❌ fetchUser uses userId from closure but deps are empty
const fetchUser = useCallback(() => {
fetch(`/api/user/${userId}`)
}, []) // stale userId after first render!
// ✅
const fetchUser = useCallback(() => {
fetch(`/api/user/${userId}`)
}, [userId])// ❌ Runs on every render including ones unrelated to items
function ProductList({ items, filter }) {
const filtered = items.filter(expensiveFilter) // O(n) every render
...
}
// ✅
const filtered = useMemo(() => items.filter(expensiveFilter), [items, filter])// ❌ Passing theme 6 levels deep
<App theme={theme}>
<Layout theme={theme}>
<Sidebar theme={theme}>
<MenuItem theme={theme} />
// ✅ Context for "ambient" data (theme, locale, current user)
// ✅ Zustand/Redux for "global app state" (cart, auth tokens)
// ✅ TanStack Query for "server state" (users, products)
// ✅ Local useState for "component-specific ephemeral UI state"Key points to cover:
- Data fetching strategy — TanStack Query
useInfiniteQuery, cursor-based pagination - Virtual scrolling —
react-windowor@tanstack/react-virtualfor thousands of tweets - Optimistic updates — Like/retweet immediately, rollback on error
- Real-time updates — WebSocket or SSE for new tweets, prepend to list
- State architecture — Separate server state (TanStack) from UI state (useReducer)
// Architecture sketch
function Feed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchTweets({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const tweets = data?.pages.flatMap(p => p.tweets) ?? []
// Virtual list — only renders ~20 DOM nodes regardless of list size
const rowVirtualizer = useVirtualizer({
count: tweets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
})
return (
<div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(item => (
<TweetCard key={item.key} tweet={tweets[item.index]}
style={{ transform: `translateY(${item.start}px)` }} />
))}
</div>
{hasNextPage && <LoadMoreTrigger onVisible={fetchNextPage} />}
</div>
)
}Key decisions:
- Compound component pattern for flexibility (no "props explosion")
- Controlled + uncontrolled support
- Accessibility: ARIA roles, keyboard nav, focus management
// ❌ Props explosion — not scalable
<Select
options={options}
onSelect={handleSelect}
renderOption={...}
optionClassName="..."
disabledOptionIds={[]}
groupBy="category"
searchable
clearable
/>
// ✅ Compound component — composable, each part is overridable
<Select value={value} onChange={setValue}>
<Select.Trigger />
<Select.Content>
<Select.Search />
<Select.Group label="Fruits">
<Select.Item value="apple">🍎 Apple</Select.Item>
<Select.Item value="banana" disabled>🍌 Banana</Select.Item>
</Select.Group>
</Select.Content>
</Select>
// Internal context passes value/onChange/open state down to children
const SelectContext = createContext<SelectContextType>(null!)
function Select({ value, onChange, children }) {
const [open, setOpen] = useState(false)
return (
<SelectContext.Provider value={{ value, onChange, open, setOpen }}>
<div role="combobox" aria-expanded={open}>{children}</div>
</SelectContext.Provider>
)
}Points the interviewer expects:
- Token strategy — HTTP-only cookies (not localStorage — XSS safe)
- Middleware —
middleware.tsprotects routes at the edge before any rendering - Session hydration — Server Component reads session, passes to client via context
- Refresh token rotation — Silent refresh without user interruption
// middleware.ts — runs at edge, zero bundle cost
import { NextResponse } from 'next/server'
import { verifyToken } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('access_token')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
const payload = await verifyToken(token)
if (!payload) {
// Try to refresh
return NextResponse.redirect(new URL('/api/auth/refresh', request.url))
}
// Inject user id into header for Server Components to read
const response = NextResponse.next()
response.headers.set('x-user-id', payload.sub)
return response
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}Format: Situation → Task → Action → Result
S: Our e-commerce product list page had 1,500ms LCP on mobile due to rendering a 200-item list with complex cards.
T: Reduce LCP to under 500ms without removing functionality.
A:
- Opened React DevTools Profiler — found
ProductCardre-rendering on every keystroke in the search box because the parent state update triggered the whole list- Wrapped
ProductCardinReact.memoand extracted the search state to a sibling component using lifting-state-up correctly- Added
react-windowvirtualisation — only ~10 cards rendered at any time- Moved heavy images to
next/imagewithloading="lazy"andsizesattributeR: LCP dropped from 1,500ms → 340ms. Bounce rate on mobile down 18%.
S: We were building a dashboard used by 15 teams. Every team wanted their own state slice, and the existing prop-drilling approach was breaking down with 8+ levels deep.
T: Choose a state management architecture that could scale without requiring teams to coordinate.
A: Evaluated Context + useReducer (too much boilerplate per team), Redux (good devtools but overhead), Zustand (simple per-slice stores). Chose Zustand with slice pattern — each team owns their slice, stores are lazy-loaded by route, devtools work out of the box.
R: New teams could onboard their slice in <1 hour. Zero cross-team state collisions in 6 months.
Strong answer structure:
- Correctness first — does it handle loading/error/empty states?
- Performance — any unnecessary re-renders? missing memoisation?
- Accessibility — keyboard nav, ARIA roles, colour contrast
- Error boundaries — are async operations wrapped?
- Tests — are key user flows covered?
- API design — if it's a component, is the props API intuitive and composable?
| Question | Answer |
|---|---|
| Controlled vs uncontrolled? | Controlled = React owns the value via state. Uncontrolled = DOM owns it via ref. |
When to use useReducer over useState? |
When next state depends on prev state in complex ways, or when multiple sub-values are related. |
What does React.memo do? |
Skips re-render if props are shallowly equal. Does NOT help if props are new object/array references. |
| What is prop spreading anti-pattern? | <Button {...props} /> passes unknown attributes to DOM, causes warnings, makes refactoring hard. |
key={Math.random()}? |
Causes full remount every render, destroying all state and DOM. Never do this. |
| When does Context cause performance issues? | When a high-frequency value (scroll position, cursor) is in context — every consumer re-renders. Split contexts or use useSyncExternalStore. |
useRef vs useState? |
useRef mutates .current without triggering re-render. Use for DOM refs, timers, previous values. |
| What is hydration error? | SSR-rendered HTML doesn't match client render (e.g. Date.now(), window access). Fix: suppressHydrationWarning or useEffect gate. |
When is useCallback worth it? |
When the function is passed as a prop to a React.memo child, or as a dep in useEffect. Pure overhead otherwise. |
Difference between null and undefined in JSX? |
Both render nothing. But undefined as a prop is different from passing no prop at all — it uses the default. |
| What is tearing in Concurrent Mode? | Different parts of UI see different versions of state mid-render. Fixed by useSyncExternalStore for external stores. |
| Server Component vs Client Component? | Server = no hooks/events, runs on server only, can async/await directly. Client = 'use client', has full React API, ships JS to browser. |
| What happens if you return a Promise from useEffect? | Nothing useful — React ignores it. Use an inner async function instead: useEffect(() => { async function run() {...}; run() }, []) |
| What is React Suspense? | Pauses rendering of a subtree while a Promise resolves (data fetching, lazy imports). Shows fallback in the meantime. |
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds
Trap: Testing implementation details (state values, component methods) makes tests brittle. Test behaviour instead.
// ❌ Testing implementation detail — breaks on refactor
expect(wrapper.state('isOpen')).toBe(true)
expect(component.find('Modal').props().visible).toBe(true)
// ✅ Testing what the user sees
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByText(/your order was placed/i)).toBeVisible()| Priority | Query | Use when |
|---|---|---|
| 1st | getByRole |
Buttons, inputs, headings, links — best for a11y |
| 2nd | getByLabelText |
Form fields associated with a label |
| 3rd | getByPlaceholderText |
Inputs with placeholder (fallback) |
| 4th | getByText |
Non-interactive text content |
| 5th | getByDisplayValue |
Select/input current value |
| 6th | getByAltText |
Images |
| 7th | getByTitle |
Rarely used |
| Last | getByTestId |
Last resort — data-testid only when no semantic query works |
// ✅ Prefer role queries — they test accessibility too
const submitBtn = screen.getByRole('button', { name: /submit/i })
const emailInput = screen.getByRole('textbox', { name: /email/i })
const heading = screen.getByRole('heading', { level: 1 })
// ✅ getByLabelText for form fields
const password = screen.getByLabelText(/password/i)import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { server } from './mocks/server' // MSW mock server
import { http, HttpResponse } from 'msw'
test('shows user name after loading', async () => {
// Override MSW handler for this test
server.use(
http.get('/api/user/1', () =>
HttpResponse.json({ name: 'Alice', email: 'alice@example.com' })
)
)
render(<UserProfile userId="1" />)
// Assert loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for async content
await screen.findByText('Alice') // findBy* = getBy* + waitFor built in
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})import userEvent from '@testing-library/user-event'
test('submits the form with valid data', async () => {
const user = userEvent.setup() // always use setup() for proper event simulation
const onSubmit = vi.fn()
render(<ContactForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/name/i), 'Bob')
await user.type(screen.getByLabelText(/email/i), 'bob@example.com')
await user.click(screen.getByRole('button', { name: /send/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Bob',
email: 'bob@example.com',
})
})
})
test('shows validation error for invalid email', async () => {
const user = userEvent.setup()
render(<ContactForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'not-an-email')
await user.click(screen.getByRole('button', { name: /send/i }))
expect(await screen.findByRole('alert')).toHaveTextContent(/valid email/i)
})// Mock a module entirely
vi.mock('@/lib/analytics', () => ({
track: vi.fn(),
}))
// Mock a hook
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({ user: { id: '1', name: 'Test User' }, isLoading: false }),
}))
// Mock with implementation per test
import { useRouter } from 'next/navigation'
vi.mock('next/navigation')
test('redirects to dashboard after login', async () => {
const push = vi.fn()
vi.mocked(useRouter).mockReturnValue({ push } as any)
const user = userEvent.setup()
render(<LoginForm />)
await user.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => expect(push).toHaveBeenCalledWith('/dashboard'))
})✅ Test:
- User flows — "user fills form → submits → sees success message"
- Error states — API failure, validation errors, empty states
- Conditional rendering based on props/state
- Accessibility attributes for interactive components
- Critical business logic in utility functions (pure functions are easy to test)
❌ Don't test:
- Implementation details (internal state, private methods)
- Third-party library internals
- Styling (CSS class names) — use visual regression tools instead
- Things that would require mocking half your app to work
// The "as" prop lets you change the underlying element
// <Button as="a" href="..."> renders an <a>, not a <button>
type ButtonProps<T extends React.ElementType = 'button'> = {
as?: T
children: React.ReactNode
variant?: 'primary' | 'secondary'
} & React.ComponentPropsWithoutRef<T>
function Button<T extends React.ElementType = 'button'>({
as,
children,
variant = 'primary',
...rest
}: ButtonProps<T>) {
const Component = as ?? 'button'
return <Component className={`btn-${variant}`} {...rest}>{children}</Component>
}
// Usage — TypeScript infers and validates the right props:
<Button>Click me</Button> // button props
<Button as="a" href="/home">Home</Button> // anchor props ✅
<Button as="a" onClick={() => {}}>Bad</Button> // both work — "a" + onClick ✅// Force mutually exclusive prop combinations at type level
type AlertProps =
| { variant: 'info' | 'success'; dismissible?: boolean; onDismiss?: () => void }
| { variant: 'error' | 'warning'; dismissible: true; onDismiss: () => void }
// ^ if error/warning, onDismiss is REQUIRED
function Alert({ variant, dismissible, onDismiss }: AlertProps) {
return (
<div role="alert" data-variant={variant}>
{dismissible && (
<button onClick={onDismiss}>✕</button> // TypeScript knows onDismiss exists here
)}
</div>
)
}
// ❌ TypeScript error — onDismiss required for 'error'
<Alert variant="error" dismissible />
// ✅
<Alert variant="error" dismissible onDismiss={() => setVisible(false)} />// Generic component that works with any data shape
interface Column<T> {
key: keyof T
header: string
render?: (value: T[keyof T], row: T) => React.ReactNode
}
interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
keyExtractor: (item: T) => string
}
function DataTable<T>({ data, columns, keyExtractor }: DataTableProps<T>) {
return (
<table>
<thead>
<tr>{columns.map(col => <th key={String(col.key)}>{col.header}</th>)}</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={keyExtractor(row)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render ? col.render(row[col.key], row) : String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
// Usage — fully typed, `columns` validates against User shape
<DataTable<User>
data={users}
keyExtractor={u => u.id}
columns={[
{ key: 'name', header: 'Name' },
{ key: 'role', header: 'Role', render: (v) => <Badge>{v}</Badge> },
{ key: 'nonExistent', header: '...' }, // ❌ TypeScript error!
]}
/>// ❌ Inferred as (boolean | () => void)[] — loses tuple type
function useToggle(initial = false) {
const [on, setOn] = useState(initial)
return [on, () => setOn(v => !v)]
}
const [on, toggle] = useToggle()
// toggle is inferred as boolean | (() => void) — wrong!
// ✅ Option 1: explicit return type annotation
function useToggle(initial = false): [boolean, () => void] {
const [on, setOn] = useState(initial)
return [on, () => setOn(v => !v)]
}
// ✅ Option 2: const assertion
function useToggle(initial = false) {
const [on, setOn] = useState(initial)
return [on, () => setOn(v => !v)] as const
}
// Inferred as readonly [boolean, () => void] ✓Before React 18: Only state updates inside React event handlers were batched. Updates in setTimeout, Promises, or native event listeners were NOT batched — caused multiple re-renders.
React 18: All state updates are batched automatically, regardless of origin.
// React 17 — this caused 2 re-renders (one per setState)
setTimeout(() => {
setCount(c => c + 1) // re-render 1
setFlag(f => !f) // re-render 2
}, 1000)
// React 18 — automatically batched → only 1 re-render
setTimeout(() => {
setCount(c => c + 1) // ↘ batched
setFlag(f => !f) // → 1 re-render total
}, 1000)
// Same applies to Promise callbacks:
fetch('/api/data').then(() => {
setData(result) // React 18: batched
setLoading(false) // React 18: batched → 1 re-render
})flushSync forces React to flush updates synchronously — opting OUT of batching. Needed when you must read the DOM immediately after a state update.
import { flushSync } from 'react-dom'
function ScrollToBottom() {
const listRef = useRef<HTMLUListElement>(null)
const addItem = () => {
// ❌ Without flushSync: item added but DOM not updated yet when we scroll
setItems(prev => [...prev, newItem])
listRef.current?.scrollTo({ top: listRef.current.scrollHeight })
// ✅ With flushSync: React updates the DOM before the next line runs
flushSync(() => {
setItems(prev => [...prev, newItem])
})
// DOM is now updated — scroll measurement is accurate
listRef.current?.scrollTo({ top: listRef.current.scrollHeight })
}
}
// Other use cases for flushSync:
// - Third-party DOM libraries that need immediate DOM updates
// - Printing (window.print()) after a state-driven layout change
// - Measuring DOM dimensions right after state updateimport { useTransition, useDeferredValue } from 'react'
// startTransition — marks an update as "non-urgent"
// React can interrupt and abandon the render if something more urgent arrives
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [isPending, startTransition] = useTransition()
const handleChange = (value: string) => {
setQuery(value) // urgent — updates input immediately (sync lane)
startTransition(() => {
setResults(heavySearch(value)) // non-urgent — can be interrupted
})
}
return (
<>
<input value={query} onChange={e => handleChange(e.target.value)} />
{isPending && <Spinner />}
<ResultsList results={results} />
</>
)
}
// useDeferredValue — like startTransition but for derived/received values
function ResultsList({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query)
// deferredQuery lags behind query — old results shown while new ones compute
const results = useMemo(() => heavySearch(deferredQuery), [deferredQuery])
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
}Next.js 14 App Router has 4 distinct caches — interviewers love this:
| Cache | What it stores | Duration | How to invalidate |
|---|---|---|---|
| Request Memoisation | fetch() deduplication within a single render tree |
Per request | Automatic (per render) |
| Data Cache | fetch() responses on the server |
Persistent (until revalidated) | revalidatePath(), revalidateTag(), cache: 'no-store' |
| Full Route Cache | Statically rendered HTML + RSC payload | Persistent | Redeploy or on-demand revalidation |
| Router Cache | Client-side cache of RSC payloads | Session / 30s (dynamic) / 5min (static) | router.refresh(), revalidatePath() |
// Control Data Cache per fetch:
fetch('/api/posts', { cache: 'no-store' }) // never cache (SSR)
fetch('/api/posts', { next: { revalidate: 60 } }) // ISR — revalidate every 60s
fetch('/api/posts', { next: { tags: ['posts'] } }) // tag-based revalidation
fetch('/api/posts') // default: cached (SSG)
// On-demand revalidation in route handler / server action:
import { revalidateTag, revalidatePath } from 'next/cache'
revalidateTag('posts') // busts all fetches tagged 'posts'
revalidatePath('/blog') // busts full route cache for /blogDecision tree:
Does it need:
├── onClick, onChange, event handlers? → Client Component
├── useState / useEffect / other hooks? → Client Component
├── Browser APIs (window, localStorage)? → Client Component
├── Real-time subscriptions / WebSocket? → Client Component
└── None of the above?
└── Does it:
├── Fetch data directly (DB, API)? → Server Component ✅
├── Access server-only secrets? → Server Component ✅
├── Import large server-only libraries? → Server Component ✅
└── Just render static/prop-driven UI? → Server Component ✅
// ✅ Server Component — zero JS sent to client
// app/dashboard/page.tsx (no 'use client')
export default async function DashboardPage() {
const data = await db.query('SELECT * FROM metrics') // direct DB access!
return <MetricsDashboard data={data} />
}
// ✅ Client Component — only for interactive parts
// components/MetricsDashboard.tsx
'use client'
export function MetricsDashboard({ data }: { data: Metric[] }) {
const [selected, setSelected] = useState<Metric | null>(null)
return (
<div>
{data.map(m => <MetricCard key={m.id} metric={m} onClick={() => setSelected(m)} />)}
{selected && <MetricDetail metric={selected} />}
</div>
)
}app/
dashboard/
page.tsx ← the page
loading.tsx ← Suspense fallback, shown while page.tsx awaits async data
error.tsx ← Error boundary, shown when page.tsx throws
not-found.tsx ← shown when notFound() is called inside page.tsx
layout.tsx ← persists across navigations within /dashboard
// loading.tsx — automatically wraps page in <Suspense>
export default function Loading() {
return <DashboardSkeleton />
}
// error.tsx — must be 'use client' (Error Boundary requires class component internals)
'use client'
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div>
<p>Something went wrong: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
// page.tsx — trigger not-found
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id)
if (!post) notFound() // renders not-found.tsx
return <PostDetail post={post} />
}// Server Actions — async functions that run on the server, called from the client
// Mark with 'use server' — can be co-located or in a separate file
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
// Runs on server — can access DB, env vars, etc.
await db.insert('posts', { title, createdAt: new Date() })
revalidatePath('/blog') // bust the route cache
}
// Client component — call the server action directly
'use client'
import { createPost } from './actions'
export function NewPostForm() {
return (
<form action={createPost}> {/* native HTML form integration */}
<input name="title" placeholder="Post title" />
<button type="submit">Create</button>
</form>
)
}
// Or call programmatically with useTransition:
const [isPending, startTransition] = useTransition()
const handleSubmit = () => {
startTransition(async () => {
await createPost(formData)
})
}function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timerId: ReturnType<typeof setTimeout>
return function (...args: Parameters<T>) {
clearTimeout(timerId)
timerId = setTimeout(() => fn(...args), delay)
}
}
// Usage
const debouncedSearch = debounce((query: string) => search(query), 300)
input.addEventListener('input', (e) => debouncedSearch(e.target.value))function throttle<T extends (...args: any[]) => any>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let lastCall = 0
return function (...args: Parameters<T>) {
const now = Date.now()
if (now - lastCall >= limit) {
lastCall = now
fn(...args)
}
}
}
// Usage — max one scroll handler call per 100ms
window.addEventListener('scroll', throttle(handleScroll, 100))function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true // handles primitives + same reference
if (typeof a !== 'object' || typeof b !== 'object') return false
if (a === null || b === null) return false
const keysA = Object.keys(a as object)
const keysB = Object.keys(b as object)
if (keysA.length !== keysB.length) return false
return keysA.every(key =>
Object.prototype.hasOwnProperty.call(b, key) &&
deepEqual((a as any)[key], (b as any)[key])
)
}
deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }) // true
deepEqual({ a: 1 }, { a: 2 }) // falsefunction useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue
try {
const item = window.localStorage.getItem(key)
return item ? (JSON.parse(item) as T) : initialValue
} catch {
return initialValue
}
})
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const next = value instanceof Function ? value(prev) : value
try {
window.localStorage.setItem(key, JSON.stringify(next))
} catch (e) {
console.warn(`useLocalStorage: failed to set "${key}"`, e)
}
return next
})
}, [key])
return [storedValue, setValue] as const
}
// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'dark')// Without Array.flat()
function flatten<T>(arr: (T | T[])[]): T[] {
return arr.reduce<T[]>((acc, item) => {
if (Array.isArray(item)) {
return acc.concat(flatten(item))
}
return [...acc, item]
}, [])
}
flatten([1, [2, [3, [4]]]]) // [1, 2, 3, 4]
// With depth control:
function flattenDepth<T>(arr: any[], depth = 1): T[] {
if (depth === 0) return arr
return arr.reduce((acc, item) =>
Array.isArray(item)
? acc.concat(flattenDepth(item, depth - 1))
: [...acc, item]
, [])
}// pipe — left to right execution
const pipe = (...fns: Function[]) => (x: unknown) =>
fns.reduce((v, f) => f(v), x)
// compose — right to left (mathematical convention)
const compose = (...fns: Function[]) => (x: unknown) =>
fns.reduceRight((v, f) => f(v), x)
// Usage — common in HOC composition
const enhance = pipe(
withAuth,
withErrorBoundary,
withAnalytics,
)
const EnhancedPage = enhance(PageComponent)- ❌ "I always use
useCallbackeverywhere for performance" — demonstrates misunderstanding (it has CPU/memory cost too) - ❌ "I avoid TypeScript, it slows me down" — red flag at senior level
- ❌ Saying "I'd just use Redux for everything" — shows lack of nuanced state management thinking
- ❌ Not mentioning error states / loading states when designing a component
- ❌ Forgetting to mention accessibility in component design questions
- ❌ "I'd optimise this later" without explaining what the optimisation would be
Last updated: March 2026