Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive

Interview Questions: useEffect Deep Dive

How to use this material:

  1. Read each question and think about your answer before reading the model answer.
  2. Practice explaining out loud — interviews test communication, not just knowledge.
  3. Pay attention to "Why interviewers ask" — it tells you what they're really evaluating.
  4. The model answers show the depth expected; tailor yours to the interview's seniority level.
  5. For coding questions, write working code before comparing with the model answer.

Beginner (Q1–Q6)

Q1. What is useEffect and when do you use it?

Why interviewers ask: They want to see if you understand the purpose of useEffect beyond "it runs code after render." They're checking whether you think in lifecycle terms (red flag) or synchronisation terms (green flag).

Model answer: useEffect is React's mechanism for synchronising a component with external systems — things outside React's rendering model like network requests, DOM APIs, timers, WebSocket connections, and third-party libraries.

The key mental model is synchronisation, not lifecycle. Instead of thinking "run this on mount," I think "keep this external system in sync with my component's current state." Every effect has three parts: setup (start syncing), cleanup (stop syncing), and dependencies (when to re-sync).

I use it when my component needs to interact with something React doesn't control — fetching data, subscribing to events, updating the document title, or connecting to external services. I don't use it for transforming data during render (that's just a calculation) or responding to user actions (that's an event handler).


Q2. What are the three dependency array configurations and what does each mean?

Why interviewers ask: This is a fundamental mechanics question. They want to see if you understand the practical differences and can predict behavior.

Model answer: There are three configurations:

  1. No array (useEffect(() => {...})) — The effect runs after every render. Useful for debugging or effects that truly need to sync after every change, but rare in production.

  2. Empty array (useEffect(() => {...}, [])) — The effect runs once after the first render and cleans up on unmount. Used for one-time setup like global event listeners or analytics page views.

  3. With dependencies (useEffect(() => {...}, [a, b])) — The effect runs after the first render and re-runs whenever any dependency changes (compared with Object.is). This is the most common pattern — fetching data when an ID changes, reconnecting when a URL changes, etc.

The important nuance: React compares dependencies using Object.is, which is like === except it treats NaN as equal to NaN. This means objects and arrays are compared by reference, not by content — two identical-looking objects at different memory addresses are "different" to React.


Q3. What is the cleanup function and when does it run?

Why interviewers ask: Missing cleanup is the #1 source of memory leaks in React apps. They want to see if you understand the lifecycle of effects.

Model answer: The cleanup function is what you return from your effect. It runs at two specific times:

  1. Before the effect re-runs (when dependencies change) — it cleans up the previous synchronisation before starting a new one.
  2. When the component unmounts — it cleans up the final synchronisation.

Importantly, the cleanup function is a closure over the render when it was created. It sees the old values, not the current ones. This is correct behavior — you're undoing what the previous setup did, not the current one.

For example, if my effect connects to a WebSocket room, the cleanup disconnects from that room. When the roomId changes, React runs cleanup (disconnect from old room), then setup (connect to new room). On unmount, it runs cleanup one final time to disconnect.


Q4. Why should you not use useEffect for derived/computed state?

Why interviewers ask: This tests your understanding of "You Might Not Need an Effect" — a key React principle. It shows if you overuse useEffect.

Model answer: When you compute state from other state or props in a useEffect, you trigger an unnecessary extra render cycle. The component renders with stale data, then the effect fires, sets new state, which triggers another render with the correct data. The user might see a flash of stale data.

Instead, compute derived values directly during render:

// Bad: useEffect for derived state
const [filtered, setFiltered] = useState([]);
useEffect(() => { setFiltered(items.filter(i => i.active)); }, [items]);

// Good: Calculate during render
const filtered = items.filter(i => i.active);
// Or with useMemo if expensive:
const filtered = useMemo(() => items.filter(i => i.active), [items]);

The rule is: if you can calculate something from existing props and state during render, do it. useEffect is only for external system synchronisation.


Q5. What is a race condition in data fetching and how do you prevent it?

Why interviewers ask: Race conditions are a real production bug. This tests if you've dealt with async complexity in React.

Model answer: A race condition in data fetching happens when two requests are in-flight and the slower one resolves last, overwriting the correct data. For example: user clicks profile A (slow API), then clicks profile B (fast API). B's response arrives first and shows B. Then A's response arrives and overwrites with A's data — but the user selected B.

Two prevention strategies:

  1. AbortController (preferred): Pass controller.signal to fetch(). In the cleanup function, call controller.abort(). This actually cancels the network request, saving bandwidth. You filter out AbortError in the catch block.

  2. Boolean flag: Set let active = true in the effect. In cleanup, set active = false. Before calling setState in the .then(), check if (active). This doesn't cancel the request, but prevents stale state updates.

AbortController is preferred in production because it cancels the request itself, not just the state update.


Q6. What is the difference between useEffect and useLayoutEffect?

Why interviewers ask: They want to see if you understand the rendering pipeline and can choose the right tool.

Model answer: Both have the same API, but differ in timing:

  • useEffect runs asynchronously after the browser paints. The user sees the updated UI before the effect runs. This is the default — it doesn't block visual updates.

  • useLayoutEffect runs synchronously before the browser paints. The user doesn't see anything until the effect completes. This blocks the visual update.

Use useLayoutEffect only when you need to measure or modify the DOM before the user sees it — like positioning a tooltip based on an element's dimensions. If you use useEffect for this, the user sees the tooltip at the wrong position for one frame before it jumps to the correct position.

For everything else (data fetching, subscriptions, logging, title updates), use useEffect. It's better for performance because it doesn't block paint.


Intermediate (Q7–Q12)

Q7. Explain why objects in the dependency array cause infinite re-renders and the strategies to fix it.

Why interviewers ask: This tests practical debugging skill and understanding of reference equality — one of the most common useEffect bugs.

Model answer: Objects in the dependency array cause infinite loops because React compares dependencies with Object.is, which compares by reference, not content. Even if an object has the same properties, a new object literal creates a new reference every render. React sees "different" → re-runs effect → effect sets state → re-render → new object → "different" → infinite loop.

I use four strategies, in order of preference:

  1. Move object inside the effect: If the object is only used in the effect, create it there. Then depend on the primitive values it was built from.

  2. Destructure to primitives: Extract const { id, type } = config and depend on [id, type] instead of [config].

  3. useMemo: const config = useMemo(() => ({...}), [id, type]) creates a stable reference.

  4. JSON.stringify: For simple objects, const key = JSON.stringify(config) converts to a string for comparison. Works but is a code smell.

The root cause is always the same: something creates a new reference every render. The fix is to either stabilize the reference or depend on primitives.


Q8. Design a custom useFetch hook. What features should it have for production use?

Why interviewers ask: This tests your ability to build reusable abstractions and your awareness of production requirements.

Model answer: A production useFetch hook needs:

  1. Loading/error/data states — preferably a discriminated union (status: 'idle' | 'loading' | 'success' | 'error') to prevent impossible state combinations.

  2. AbortController — for race condition prevention and request cancellation.

  3. HTTP error handlingresponse.ok check, not just network errors.

  4. Enabled flag — to conditionally skip fetching (e.g., enabled: !!userId).

  5. Refetch function — to manually trigger a re-fetch.

  6. Cache (optional) — at minimum a stale-while-revalidate pattern to show cached data while re-fetching.

The hook signature would be: useFetch(url, { enabled, cacheKey, cacheTTL }) returning { data, isLoading, isError, error, refetch }.

The internal implementation uses useEffect with AbortController cleanup, a boolean flag for extra safety, and optionally a Map-based cache with TTL timestamps.


Q9. What does React Strict Mode do to effects and why?

Why interviewers ask: Strict Mode behavior confuses many developers. Understanding it shows depth.

Model answer: In development, Strict Mode double-invokes effects: it runs setup → cleanup → setup. The component mounts, immediately unmounts, then mounts again.

This exists to catch effects that don't clean up properly. If your effect only works when setup runs once (like appending to a list without cleanup), Strict Mode reveals the bug by running setup twice.

The test is: does setup → cleanup → setup produce the same result as just setup? If yes, your cleanup is correct. If not (like having two event listeners instead of one), you have a missing or broken cleanup.

This only happens in development — production runs effects normally. The fix is always to add proper cleanup, never to disable Strict Mode.


Q10. Explain "You Might Not Need an Effect" with five specific examples.

Why interviewers ask: This tests if you've internalized React's guidance on effect minimization — a sign of React maturity.

Model answer:

  1. Transforming data for render: Don't useEffect(() => setFiltered(items.filter(...)), [items]). Instead: const filtered = items.filter(...) during render.

  2. Responding to user events: Don't useEffect(() => { if (submitted) sendForm(); }, [submitted]). Instead: call sendForm() directly in the submit handler.

  3. Initializing state from props: Don't useEffect(() => setName(user.name), [user]). Instead: useState(user.name) and use key={user.id} to reset.

  4. Notifying parent of state change: Don't useEffect(() => onChange(value), [value]). Instead: call onChange(newValue) in the same handler that sets the value.

  5. Caching expensive calculations: Don't useEffect(() => setResult(heavyCalc(data)), [data]). Instead: useMemo(() => heavyCalc(data), [data]).

The principle: useEffect is for synchronizing with external systems. If the "side effect" is just computing something from existing state, it's not a real side effect — it's a calculation that belongs in the render path.


Q11. How do you implement debounced search with useEffect?

Why interviewers ask: Debouncing is a practical, real-world pattern that tests timer cleanup understanding.

Model answer: I'd build a useDebouncedValue hook that delays propagating a value:

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  
  return debounced;
}

In the search component: const debouncedQuery = useDebouncedValue(query, 400). The fetch effect depends on debouncedQuery, not query. Each keystroke resets the timer via cleanup. Only after the user pauses for 400ms does the debounced value update and trigger the fetch.

The key insight is that cleanup (clearTimeout) prevents premature execution. Each new keystroke clears the previous timer and starts fresh. This is cleanup at its most elegant — undoing the setup (timer) before starting a new one.


Q12. Walk through the exact execution order when a component with multiple effects re-renders.

Why interviewers ask: This tests deep understanding of React's internal execution model.

Model answer: Given a component with two effects and a dependency change:

1. React calls the component function (render phase)
   - All hooks run in order, registering new effects
   - Returns new JSX

2. React diffs old vs new JSX (reconciliation)
   - Calculates minimal DOM changes

3. React commits DOM changes (commit phase)
   - Updates the actual DOM

4. Browser paints (visual update)
   - User sees the new UI NOW

5. React runs ALL cleanup functions from previous render
   - Cleanup A (from previous render's values)
   - Cleanup B (from previous render's values)
   - In declaration order

6. React runs ALL setup functions from current render
   - Setup A (with current render's values)
   - Setup B (with current render's values)
   - In declaration order

Key points: cleanups run before setups (all cleanups first, then all setups). Children's effects run before parents' (bottom-up). Everything happens after paint, so it's non-blocking.


Advanced (Q13–Q18)

Q13. How would you architect data fetching in a large React application in 2026?

Why interviewers ask: This tests architectural thinking and awareness of the React ecosystem's evolution.

Model answer: The landscape has three layers, and I'd choose based on the use case:

Layer 1: Server Components (Next.js App Router) For initial page data, I'd use React Server Components with async/await. Data fetches happen on the server, results are streamed to the client. No useEffect, no loading spinners for initial content. Best for SEO-critical and first-load-performance-critical data.

Layer 2: TanStack Query (Client-side interactive data) For data that changes after initial render — user interactions, real-time updates, mutations — I'd use TanStack Query. It handles caching, background refetching, optimistic updates, retry, deduplication, and devtools out of the box. This replaces 95% of useEffect-based data fetching.

Layer 3: useEffect (non-HTTP external systems) I'd reserve useEffect for genuine external system synchronisation: WebSocket connections, browser API subscriptions (resize, intersection, media queries), third-party library lifecycle, and the rare case where a lightweight fetch doesn't warrant TanStack Query.

The architecture principle: data that the server already has → Server Components. Data the client needs to interact with → TanStack Query. Non-HTTP external systems → useEffect.


Q14. Explain the stale closure problem in useEffect and three ways to solve it.

Why interviewers ask: Stale closures are one of the most confusing bugs in hooks. Understanding them shows deep knowledge of JavaScript and React internals.

Model answer: A stale closure happens when an effect's callback captures a value from a previous render and never gets updated. Classic example:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // count is ALWAYS 0!
  }, 1000);
  return () => clearInterval(id);
}, []); // Empty deps → effect never re-runs → count stuck at render 0's value

The interval's callback closes over count = 0 from the first render. Because deps is [], the effect never re-runs, so the callback is never replaced.

Three solutions:

  1. Updater function: setCount(c => c + 1) — doesn't read count at all. The updater receives the current value as an argument.

  2. Include in deps: }, [count]) — effect re-runs when count changes, getting a fresh closure. But this creates/destroys the interval every second.

  3. useRef for latest value: Store count in a ref (countRef.current = count every render). The interval reads countRef.current which always points to the latest value. The ref itself is a stable reference, so it doesn't need to be in deps.

The fundamental insight: each render creates a new closure with that render's values "baked in." If the effect never re-runs, it's forever stuck with the first render's values.


Q15. Design a useWebSocket hook with automatic reconnection and exponential backoff.

Why interviewers ask: This is a complex real-world useEffect challenge that tests cleanup, error handling, and state management.

Model answer: The hook needs to manage: connection lifecycle, message handling, reconnection logic, and proper cleanup.

Key design decisions:

  • Use a ref for the WebSocket instance (mutable, doesn't trigger re-renders)
  • Use a ref for the reconnection timeout (needs cleanup)
  • Use a ref for retry count (mutable counter)
  • Use a cancelled flag in the cleanup to prevent reconnection after unmount

The exponential backoff formula: delay = min(1000 * 2^retries, 30000) — doubles each retry up to 30 seconds.

Setup creates the WebSocket, attaches handlers (onopen, onmessage, onclose, onerror). On close, if not cancelled, schedule reconnection after backoff delay. On successful connection, reset retry counter.

Cleanup must: set cancelled flag, clear reconnection timeout, and close the WebSocket. The cancelled flag is crucial — without it, the onclose handler would try to reconnect even after unmount.

The hook returns { status, lastMessage, send } where send is a memoized function using the ref.


Q16. You're reviewing a PR where a junior developer used useEffect for every piece of derived state. How do you explain the problem and guide them to fix it?

Why interviewers ask: This tests mentoring ability, communication skills, and practical React knowledge.

Model answer: I'd start with the performance impact: each useEffect that sets state triggers an extra render. If they have 5 such effects, the component renders 6 times instead of once. Users see a flash of incorrect data on each render cycle.

Then I'd introduce the decision framework: "Before writing useEffect, ask: Am I synchronizing with something outside React? If not, you probably don't need it."

I'd walk through each case:

  • Filtering/sorting data → calculate during render or useMemo
  • Formatting props for display → calculate during render
  • Combining multiple states → derive during render
  • Setting state from props → useState(prop) + key for reset

For each refactored case, I'd show the before (useEffect + extra state) and after (direct calculation). Then I'd point them to the React docs "You Might Not Need an Effect" page.

The teaching principle: useEffect is for exit hatches to the world outside React. Everything computable from existing state/props should be computed during render.


Q17. How do you test components that use useEffect for data fetching? Outline your testing strategy.

Why interviewers ask: Testing async effects is challenging. This reveals testing maturity and practical experience.

Model answer: My strategy has three layers:

1. Hook unit tests (React Testing Library renderHook) Test the useFetch hook in isolation. Mock fetch with jest.fn(). Test: loading state on mount, success state after fetch resolves, error state on failure, cleanup (abort) on unmount, race condition prevention by changing URL quickly.

2. Component integration tests (React Testing Library) Test the full component with mocked fetch. Use msw (Mock Service Worker) to intercept network requests — more realistic than jest mocking. Test: loading skeleton appears, data renders after fetch, error state shows retry button, retry works, search debouncing (with fake timers).

3. Timing tests (fake timers) For debounce and interval effects: jest.useFakeTimers(). Advance time with jest.advanceTimersByTime(). Verify that debounced values update after delay and that cleanup clears timers on unmount.

Key patterns:

  • waitFor(() => expect(...)) for async state updates
  • act(() => jest.advanceTimersByTime(300)) for timer-based effects
  • Unmount the component and verify no state updates occur (cleanup works)
  • jest.spyOn(global, 'fetch') for verifying abort was called

Q18. Compare useEffect-based data fetching with React Server Components and TanStack Query. When would you use each?

Why interviewers ask: This tests architectural awareness and ability to make technology choices with trade-offs.

Model answer:

useEffect data fetching:

  • Pros: No library dependency, full control, simple for one-off fetches, works everywhere
  • Cons: Must handle loading/error/cache/race conditions manually, request waterfalls, no deduplication, no background refetching
  • Use when: Learning, tiny apps, non-HTTP external systems, WebSocket/SSE

TanStack Query:

  • Pros: Automatic caching (stale-while-revalidate), background refetching, retry, deduplication, optimistic updates, devtools, pagination helpers
  • Cons: Library dependency (~13KB), learning curve, might be overkill for 1-2 fetches
  • Use when: Production apps with interactive data, any app with >3 fetch points, real-time data needs, team projects

React Server Components:

  • Pros: No client-side JS for data fetching, no loading states for initial data, SEO-friendly, direct database access, streaming
  • Cons: Only in frameworks (Next.js), can't fetch on client interactions, requires server, new mental model
  • Use when: Initial page load data, SEO-critical pages, data that doesn't change based on client interaction

In practice, I use all three: RSC for initial data, TanStack Query for client-side interactivity, and useEffect for WebSockets and browser API subscriptions. The architecture looks like: server fetches the shell → client hydrates → TanStack Query manages interactive data → useEffect handles real-time connections.


Practice explaining these answers out loud. The best interview answers are conversational, not rehearsed.