← Back to question bank
React AppMidMedium#4003 · 40m

Implement useEffect cleanup in a product component

Use useEffect cleanup to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

useEffect cleanup is the question that exposes whether you understand React 18 Strict Mode and async lifecycle. The interview signal is naming the rule before writing code: every effect that opens a resource (subscription, listener, timer, fetch) must return a function that closes that exact resource. The cleanup runs on dependency change AND on unmount AND a second time in Strict Mode development renders.

Separate three sources of staleness: identity (an old subscription writes to an old setState), order (a slow effect resolves after a faster one), and lifecycle (an unmount in flight while async work is still pending). A senior solution names all three. The "active" flag handles the first, AbortController plus ignore-after-unmount handles the second, and a clean unsubscribe handles the third.

Volunteer the dangerous cases. A function prop that changes identity every render destroys the cleanup contract because the effect re-fires every commit. A subscribe callback that closes over stale state captures the wrong values forever. A cleanup that throws (or forgets to remove a listener) leaks across hot reloads and Strict Mode. The reference component shows the canonical shape: depend on stable inputs, register a flag, return a clean disposer.

Reference Implementation: Subscribe With Strict-Mode-Safe Cleanup

A ChannelView that subscribes by channelId and unsubscribes deterministically on dependency change, unmount, and Strict Mode double-invocation.

type ChannelMessage = { id: string; body: string; receivedAt: number };

type SubscribeFn = (
  channelId: string,
  onMessage: (message: ChannelMessage) => void
) => () => void;

type ChannelViewProps = {
  channelId: string;
  subscribe: SubscribeFn;
};

export function ChannelView({ channelId, subscribe }: ChannelViewProps) {
  const [messages, setMessages] = React.useState<ChannelMessage[]>([]);

  React.useEffect(() => {
    // The cleanup is the contract. If subscribe() opens a socket or attaches
    // a listener, every dependency change must close that exact connection
    // before opening the next one. Otherwise React renders for one channel
    // while the previous channel keeps writing into setMessages.
    let active = true;
    const unsubscribe = subscribe(channelId, (message) => {
      if (!active) return;
      setMessages((previous) => {
        // Defensive merge: dedupe by id so a server replay does not push
        // duplicate rows after a reconnect.
        if (previous.some((existing) => existing.id === message.id)) return previous;
        return [...previous, message];
      });
    });

    // Strict Mode runs effects twice in development. The active flag plus
    // unsubscribe guarantee the second run sees a clean slate.
    return () => {
      active = false;
      unsubscribe();
    };
  }, [channelId, subscribe]);

  // Resetting the local list when channel changes is correctness, not polish.
  // Without it, switching from #alpha to #beta shows the union of both.
  React.useEffect(() => {
    setMessages([]);
  }, [channelId]);

  return (
    <section aria-labelledby="channel-title">
      <h2 id="channel-title">#{channelId}</h2>
      <ul aria-live="polite">
        {messages.map((message) => (
          <li key={message.id}>{message.body}</li>
        ))}
      </ul>
    </section>
  );
}

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.

Lifecycle
Test prop change re-subscription, unmount cleanup, and Strict Mode double-mount. Assert the unsubscribe is called for every subscribe.
Staleness
Drive a message arrival after unmount; setMessages must not fire. Drive a slow handler that arrives after a fresh subscription; the old payload must not appear.
Stability
Verify the effect does not re-fire when unrelated parent state changes. The test should fail if the parent forgot to memoize the subscribe prop.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';

describe('ChannelView', () => {
  it('unsubscribes from the previous channel before subscribing to the next', () => {
    const handlers = new Map<string, (msg: any)=> void>();
    const unsubscribed: string[] = [];

    const subscribe = vi.fn((id: string, onMessage: (msg: any) => void) => {
      handlers.set(id, onMessage);
      return () => {
        handlers.delete(id);
        unsubscribed.push(id);
      };
    });

    const { rerender } = render(<ChannelView channelId="alpha" subscribe={subscribe} />);
    act(() => handlers.get('alpha')!({ id: '1', body: 'Hi alpha', receivedAt: 1 }));
    expect(screen.getByText('Hi alpha')).toBeInTheDocument();

    rerender(<ChannelView channelId="beta" subscribe={subscribe} />);
    expect(unsubscribed).toContain('alpha');
    expect(screen.queryByText('Hi alpha')).toBeNull();

    act(() => handlers.get('beta')!({ id: '2', body: 'Hi beta', receivedAt: 2 }));
    expect(screen.getByText('Hi beta')).toBeInTheDocument();
  });

  it('ignores messages received after unmount', () => {
    const handlers = new Map<string, (msg: any)=> void>();
    const subscribe = vi.fn((id: string, onMessage: (msg: any) => void) => {
      handlers.set(id, onMessage);
      return () => handlers.delete(id);
    });
    const { unmount } = render(<ChannelView channelId="alpha" subscribe={subscribe} />);
    unmount();
    // After unmount, the handler is gone from the map; no setState fires.
    expect(handlers.has('alpha')).toBe(false);
  });
});

Interviewer Signal

Shows whether you can explain React behavior while building maintainable product UI.

Constraints

  • Name what is render-derived and what is stored state.
  • Keep side effects owned by events or effects deliberately.
  • Provide a testable boundary for business logic.

Model Answer Shape

  • Start from the user workflow and state ownership.
  • Move pure decisions out of JSX when the branch logic grows.
  • Use React primitives to express ownership, not to hide unclear state.

Tradeoffs

  • Colocating state improves clarity until sibling coordination becomes the real problem.
  • Memoization helps only after render cost or identity churn is measured.

Edge Cases

  • Strict Mode re-running development effects.
  • Stale closures after async work resolves.
  • Unmounts and route changes during in-flight operations.

Testing And Proof

  • Reducer or pure function test for core state transitions.
  • Interaction test for the user workflow.
  • Regression case for stale or repeated async behavior.

Follow-Ups

  • How would this change with server rendering?
  • Where would you place this state in a larger app?