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