← Back to question bank
DebuggingSeniorHard#4014 · 35m

Debug external store subscription under interview pressure

A React screen using external store subscription 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 "useState + useEffect to bridge an external store into React". It looks reasonable: subscribe on mount, setState on change, unsubscribe on unmount. Two failure modes ship in React 18+. Under concurrent rendering, two consumers can commit at different times and observe different values (tearing). And if getSnapshot returns a fresh object each call, React’s Object.is check fails every time and the component infinite-loops. The fix is useSyncExternalStore, which solves both.

Locate the boundary by asking three questions. (1) Does the store guarantee referentially stable snapshots when value is unchanged? (2) Does subscribe return an unsubscribe function and notify on every meaningful change? (3) Does getServerSnapshot return a server-safe constant that the client can hydrate against? If yes to all three, useSyncExternalStore is correct out of the box. If no, fix the store API before fixing the React side.

Adjacent traps: returning a derived object from getSnapshot (selector logic must run inside the consumer with useMemo, not inside the store), notifying listeners on every set even when value is unchanged (causes pointless re-renders), and forgetting getServerSnapshot (SSR will throw an error during hydration). The regression test asserts both the no-tearing contract and the no-infinite-loop contract.

Regression Fix: useSyncExternalStore With Stable Snapshots

The fixed counter store returns the same snapshot reference until value actually changes; consumers go through useSyncExternalStore.

// THE BUG: the original code used useState + useEffect to mirror the
// external store, which is unsafe under concurrent rendering: two
// consumers in the same tree could land in different commits and render
// with different values (tearing). It also created a new snapshot object
// every getSnapshot call, which forced infinite re-renders. The fix uses
// useSyncExternalStore with a getSnapshot that returns referentially
// stable values.

type Counter = { value: number };

function createCounterStore() {
  let snapshot: Counter = { value: 0 };
  const listeners = new Set<() => void>();

  return {
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    // Returning the same reference when value is unchanged is the
    // contract; React calls Object.is on consecutive snapshots.
    getSnapshot() {
      return snapshot;
    },
    getServerSnapshot(): Counter {
      return { value: 0 };
    },
    increment() {
      snapshot = { value: snapshot.value + 1 };
      listeners.forEach((listener) => listener());
    },
  };
}

export const counterStore = createCounterStore();

export function useCounter() {
  return React.useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot,
    counterStore.getServerSnapshot
  );
}

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 two consumers and trigger an update inside startTransition. The broken useState+useEffect implementation can render them in different commits; the regression test asserts they always agree.
Patch
Replace the bridge with useSyncExternalStore. Audit getSnapshot to ensure it returns referentially stable values; if the store creates new objects on every call, fix the store first.
Prevent
Add a server-side render test that calls getServerSnapshot and asserts no exceptions. Pair with a Strict Mode double-mount test for the subscribe path.
import { describe, it, expect } from 'vitest';
import { render, screen, act } from '@testing-library/react';

describe('useCounter regression', () => {
  it('two consumers in the same tree see the same value (no tearing)', () => {
    function Twin() {
      const a = useCounter();
      const b = useCounter();
      return (
        <span data-testid="twin">
          {a.value === b.value ? 'sync' : 'torn:' + a.value + ':' + b.value}
        </span>
      );
    }
    render(<Twin />);
    act(() => {
      counterStore.increment();
      counterStore.increment();
    });
    // The broken useState+useEffect implementation could land the two
    // consumers in different commits when paired with startTransition,
    // producing a torn render. useSyncExternalStore prevents that.
    expect(screen.getByTestId('twin')).toHaveTextContent('sync');
  });

  it('does not infinite-loop when getSnapshot returns the same reference', () => {
    let renders = 0;
    function Probe() {
      renders++;
      useCounter();
      return null;
    }
    render(<Probe />);
    expect(renders).toBeLessThan(5);
  });
});

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?