Skip to content

Latest commit

 

History

History
1430 lines (1104 loc) · 42.9 KB

File metadata and controls

1430 lines (1104 loc) · 42.9 KB

React Interview Prep — Senior to Staff Engineer

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.


Table of Contents

  1. useEffect Deep Dive
  2. Custom Hook Patterns
  3. useLayoutEffect vs useEffect
  4. Strict Mode Behaviour
  5. Memory Leaks & Cleanup
  6. Stale Closures & Dependency Arrays
  7. Common "What's Wrong With This Code?" Traps
  8. System Design Questions
  9. Behavioural / STAR Questions
  10. Quick-Fire Q&A Cheatsheet
  11. Testing Patterns — RTL & Vitest
  12. TypeScript-Specific React Patterns
  13. React 18 — Automatic Batching & flushSync
  14. Next.js Interview Questions
  15. Implement From Scratch — Coding Challenges

1. useEffect Deep Dive

Q: What is the mental model for useEffect?

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.

Q: What happens if you fetch data in useEffect without handling race conditions?

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."


Q: Why does this cause an infinite loop?

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.


Q: When does the cleanup function run?

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.


2. Custom Hook Patterns

Q: Write a useDebounce hook

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)} />
}

Q: Write a usePrevious hook

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>
}

Q: Write a useEventListener hook

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()
  })
}

Q: Write a useIntersectionObserver hook for infinite scroll

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>
  )
}

3. useLayoutEffect vs useEffect

Q: When do you use useLayoutEffect over useEffect?

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 state

Rule of thumb: Default to useEffect. Only switch to useLayoutEffect if you see a flash/flicker.


4. Strict Mode Behaviour

Q: Why does my effect run twice in development?

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

5. Memory Leaks & Cleanup

Q: Name three ways a React app can leak memory

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)
}, [])

6. Stale Closures & Dependency Arrays

Q: What is a stale closure in React?

// ❌ 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 state

Real-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."


Q: When is it safe to omit something from the dependency array?

Only when:

  1. It's a stable reference that never changes — useState setter, useReducer dispatch, useRef object
  2. You intentionally want to capture the initial value only (document it with a comment)
  3. You're using a ref as the bridge (read ref.current inside the effect, update it in a separate useEffect)
// 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

7. Common "What's Wrong With This Code?" Traps

Trap 1: Missing key causing state bleed

// ❌ 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} />)}

Trap 2: Object/array literal in JSX causing re-renders

// ❌ 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}>

Trap 3: Forgetting that useState setter is asynchronous

// ❌ 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])

Trap 4: useCallback with missing deps

// ❌ 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])

Trap 5: Expensive computation in render without memoisation

// ❌ 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])

Trap 6: Prop drilling vs Context vs State manager

// ❌ 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"

8. System Design Questions

Q: Design a Twitter/X news feed component

Key points to cover:

  1. Data fetching strategy — TanStack Query useInfiniteQuery, cursor-based pagination
  2. Virtual scrollingreact-window or @tanstack/react-virtual for thousands of tweets
  3. Optimistic updates — Like/retweet immediately, rollback on error
  4. Real-time updates — WebSocket or SSE for new tweets, prepend to list
  5. 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>
  )
}

Q: Design a component API for a reusable <Select> / dropdown

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>
  )
}

Q: Design an auth flow for a Next.js app

Points the interviewer expects:

  1. Token strategy — HTTP-only cookies (not localStorage — XSS safe)
  2. Middlewaremiddleware.ts protects routes at the edge before any rendering
  3. Session hydration — Server Component reads session, passes to client via context
  4. 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*'],
}

9. Behavioural / STAR Questions

Format: Situation → Task → Action → Result


"Tell me about a React performance issue you solved"

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:

  1. Opened React DevTools Profiler — found ProductCard re-rendering on every keystroke in the search box because the parent state update triggered the whole list
  2. Wrapped ProductCard in React.memo and extracted the search state to a sibling component using lifting-state-up correctly
  3. Added react-window virtualisation — only ~10 cards rendered at any time
  4. Moved heavy images to next/image with loading="lazy" and sizes attribute

R: LCP dropped from 1,500ms → 340ms. Bounce rate on mobile down 18%.


"Tell me about a time you had to make a difficult technical architecture decision"

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.


"How do you approach code reviewing a React PR?"

Strong answer structure:

  1. Correctness first — does it handle loading/error/empty states?
  2. Performance — any unnecessary re-renders? missing memoisation?
  3. Accessibility — keyboard nav, ARIA roles, colour contrast
  4. Error boundaries — are async operations wrapped?
  5. Tests — are key user flows covered?
  6. API design — if it's a component, is the props API intuitive and composable?

10. Quick-Fire Q&A Cheatsheet

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.


11. Testing Patterns — RTL & Vitest

Q: What is the guiding principle of React Testing Library?

"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()

Q: Which RTL query should you use and when?

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)

Q: How do you test async behaviour?

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()
})

Q: How do you test user interactions?

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)
})

Q: How do you mock modules and hooks?

// 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'))
})

Q: What should you test vs not test?

✅ 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

12. TypeScript-Specific React Patterns

Q: How do you type a component that accepts as polymorphic prop?

// 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 

Q: How do you type discriminated union props?

// 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)} />

Q: How do you type generic list/table components?

// 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!
  ]}
/>

Q: How do you create a properly typed custom hook return?

// ❌ 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] ✓

13. React 18 — Automatic Batching & flushSync

Q: What is automatic batching and why does it matter?

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
})

Q: When do you need flushSync?

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 update

Q: What is startTransition and when should you use it?

import { 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>
}

14. Next.js Interview Questions

Q: Explain the Next.js caching layers

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 /blog

Q: When do you choose Server Component vs Client Component?

Decision 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>
  )
}

Q: What is the difference between loading.tsx, error.tsx, and not-found.tsx?

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} />
}

Q: How do Server Actions work?

// 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)
  })
}

15. Implement From Scratch — Coding Challenges

Challenge: Implement debounce

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))

Challenge: Implement throttle

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))

Challenge: Implement deep equal

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 })                              // false

Challenge: Implement useLocalStorage hook

function 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')

Challenge: Implement flatten nested array

// 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]
  , [])
}

Challenge: Implement pipe / compose

// 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)

Extra: Interview Red Flags to Avoid

  • ❌ "I always use useCallback everywhere 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