← Back to question bank
DebuggingSeniorHard#4032 · 35m

Debug render props versus hooks under interview pressure

A React screen using render props versus hooks 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 "two APIs, two implementations". A custom hook does the work for hook callers; a render-prop component does the work again for callers who prefer that shape. The implementations duplicate fetch logic, abort handling, and error mapping. They drift over time as one is fixed but not the other, and bugs ship to the API the maintainer is not currently using. The fix is a single private engine that both APIs delegate to.

Locate the boundary by asking "what is the engine, and what is the API surface?". The engine owns the lifecycle (fetch, abort, dispatch). The API decides how callers consume it. A hook returns the engine state directly; a render-prop wraps the engine and passes state through children. Both paths import the same engine; if the engine changes, both APIs change together.

Adjacent traps: the render-prop’s children function changing identity every render (forces engine effects to re-fire — the render-prop should still memoize its callback), exposing different field shapes between APIs ("isLoading" vs "status: 'loading'" — pick one), and adding new functionality only to the hook because it is "easier" (the asymmetry produces the original bug class). The regression test asserts the key sets match exactly.

Regression Fix: Single Engine, Two API Surfaces

The fixed download API factors the engine into a private hook; useDownload and Download both delegate, so they cannot drift.

// THE BUG: the original render-prop component duplicated logic from the
// hook version: two implementations of fetch, abort, and error handling
// that drifted apart over time. The fix factors the engine into one
// hook and re-uses it inside both APIs, so the hook and the render-prop
// stay in sync by construction.

type DownloadState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'done' }
  | { status: 'error'; error: Error };

// THE single engine. Both APIs delegate here.
function useDownloadEngine(url: string) {
  const [state, setState] = React.useState<DownloadState>({ status: 'idle' });
  const start = React.useCallback(() => {
    setState({ status: 'loading' });
    const controller = new AbortController();
    fetch(url, { signal: controller.signal })
      .then(() => setState({ status: 'done' }))
      .catch((error: Error) => {
        if (controller.signal.aborted) return;
        setState({ status: 'error', error });
      });
    return () => controller.abort();
  }, [url]);
  return { state, start };
}

export function useDownload(url: string) {
  return useDownloadEngine(url);
}

export function Download({
  url,
  children,
}: {
  url: string;
  children: (api: ReturnType<typeof useDownloadEngine>) => React.ReactElement;
}) {
  const api = useDownloadEngine(url);
  return children(api);
}

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
Inspect the two duplicate implementations in the broken version. Note any field that exists on one and not the other; assert the regression test would have caught the divergence at PR time.
Patch
Extract the engine. Both APIs delegate. Test that their public shapes match exactly. Adding a new field requires a single change in the engine.
Prevent
Document the engine as the source of truth in the README; mark the public APIs as thin wrappers. Pair with a contract test that asserts API parity for any newly added engine field.
import { describe, it, expect } from 'vitest';
import { render, screen, renderHook } from '@testing-library/react';

describe('engine sharing regression', () => {
  it('hook API and render-prop API expose the same shape', () => {
    let renderPropApi: ReturnType<typeof useDownload> | null = null;
    render(
      <Download url="about:blank">
        {(api) => {
          renderPropApi = api;
          return <span data-testid="probe" />;
        }}
      </Download>
    );
    const { result: hookApi } = renderHook(() => useDownload('about:blank'));
    // The two surfaces share a single engine; their key sets must match.
    expect(Object.keys(renderPropApi!).sort()).toEqual(Object.keys(hookApi.current).sort());
  });
});

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?