← Back to question bank
DebuggingSeniorHard#4004 · 35m

Debug useEffect cleanup under interview pressure

A React screen using useEffect cleanup passes simple tests but breaks during repeated interaction. Find the likely root cause, patch it, and describe the longer-term design improvement.

Answer Strategy

The broken pattern in this question is "subscribe in useEffect without returning a cleanup". The bug is invisible on the happy path: a single channel subscription works perfectly. The moment the user switches channels or the component unmounts, the missing cleanup leaks listeners and, in Strict Mode, doubles them on every render. The fix is the cleanup function — useEffect cleanup is the contract, not an optional polish.

Locate the boundary by asking "what does subscribe own?". A subscription is an external resource: a socket, an interval, a listener. Anything you open in an effect must be closed in the returned cleanup, on dependency change AND on unmount. The active flag is a second guard for the case where async work resolves between cleanup running and React processing the next commit.

Adjacent traps: returning the unsubscribe inline (return subscribe(...)) without saving the closure (still works, but obscures intent and breaks if subscribe gains a return value other than the unsubscribe), forgetting to clear local list state on dependency change (leaks data across switches even when the subscription is correct), and depending on a function prop that changes identity every render (cleanup runs every commit; memoize at the call site).

Regression Fix: Symmetric Subscribe + Unsubscribe With Active Flag

The fixed Chat: every subscribe returns a deterministic unsubscribe; the active flag guards stale callbacks; channel switch resets local state.

// THE BUG: the original effect subscribed without returning an unsubscribe.
// Every dependency change opened a fresh subscription on top of the old
// one, so messages multiplied across channel switches and remained alive
// after unmount — a classic listener leak.

type Subscribe = (channelId: string, onMessage: (msg: { id: string; body: string }) => void) => () => void;

type ChatProps = { channelId: string; subscribe: Subscribe };

export function Chat({ channelId, subscribe }: ChatProps) {
  const [messages, setMessages] = React.useState<{ id: string; body: string }[]>([]);

  React.useEffect(() => {
    let active = true;
    const unsubscribe = subscribe(channelId, (msg) => {
      if (!active) return;
      setMessages((current) => [...current, msg]);
    });
    return () => {
      active = false;
      unsubscribe();
    };
  }, [channelId, subscribe]);

  // The list resets on channel change. Without this second effect the user
  // saw the previous channel's history above the new channel's first
  // message — a different correctness bug from the leak itself.
  React.useEffect(() => {
    setMessages([]);
  }, [channelId]);

  return (
    <ul aria-label={'channel-' + channelId}>
      {messages.map((m) => (
        <li key={m.id}>{m.body}</li>
      ))}
    </ul>
  );
}

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Reproduce
Render the broken version with channelId=alpha, then re-render with channelId=beta. Assert the alpha unsubscribe never ran. The original code keeps the alpha listener attached and writes alpha messages into the beta view.
Patch
Add the cleanup function. Test that subscribe and unsubscribe call counts are equal across the lifecycle: every subscribe pairs with exactly one unsubscribe.
Prevent
Add a Strict Mode test path; the double-mount must not produce ghost listeners or duplicated messages. Strict Mode is exactly the regression-protection feature this bug requires.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';

describe('Chat regression', () => {
  it('unsubscribes from the previous channel before subscribing to the next', () => {
    const live = new Map<string, (msg: any)=> void>();
    const released: string[] = [];
    const subscribe = vi.fn((id: string, onMessage: (msg: any) => void) => {
      live.set(id, onMessage);
      return () => {
        live.delete(id);
        released.push(id);
      };
    });

    const { rerender } = render(<Chat channelId="alpha" subscribe={subscribe} />);
    rerender(<Chat channelId="beta" subscribe={subscribe} />);
    expect(released).toContain('alpha');
    expect(live.has('alpha')).toBe(false);
  });

  it('does not setState after unmount when a late message arrives', () => {
    const live = new Map<string, (msg: any)=> void>();
    const subscribe = vi.fn((id: string, onMessage: (msg: any) => void) => {
      live.set(id, onMessage);
      return () => live.delete(id);
    });
    const { unmount } = render(<Chat channelId="alpha" subscribe={subscribe} />);
    const handler = live.get('alpha')!;
    unmount();
    // Even if the broker fires a late message after unmount, the active
    // flag and the cleared subscription prevent any setState.
    act(() => handler({ id: '1', body: 'late' }));
    expect(screen.queryByText('late')).toBeNull();
  });
});

Interviewer Signal

Tests whether you debug from ownership and lifecycle instead of random dependency-array edits.

Constraints

  • State a hypothesis before changing code.
  • Name what evidence would confirm the bug.
  • Avoid broad rewrites unless the current API cannot express the behavior.

Model Answer Shape

  • Reproduce the failing sequence first.
  • Inspect ownership boundaries: local state, props, effects, subscriptions, and server data.
  • Patch the minimal broken boundary and add a regression test.

Tradeoffs

  • A minimal patch reduces risk, but repeated lifecycle bugs often justify a small reducer or custom hook.
  • Adding dependencies can silence lint warnings while still preserving the wrong ownership model.

Edge Cases

  • Double clicks and repeated submissions.
  • Slow network responses arriving out of order.
  • Component remount with stale persisted state.

Testing And Proof

  • Failing interaction sequence.
  • Out-of-order async response.
  • Unmount cleanup.

Follow-Ups

  • What would the code review comment say?
  • What metric or log would show this in production?