← Back to question bank
React AppSeniorHard#4031 · 55m

Implement render props versus hooks in a product component

Use render props versus hooks to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

Render props vs hooks is the question that exposes whether you can separate engine from API. The senior answer is "do both on top of one engine, choose the surface by ergonomics". A custom hook composes top-down: parent owns the JSX, the hook returns state and actions. A render-prop component owns the lifecycle and lets the caller control rendering by passing a children function. Both can share the same internal useDownloadEngine — duplicating the logic is the failure mode.

Pick the surface by what callers need to compose. If the API is one engine + multiple consumers in different parts of the tree, hooks are cleaner because each consumer calls the hook independently. If the API is "I own the data, you choose the rendering shape", render-props are more honest because the children function makes the rendering contract explicit. If you find yourself drilling props through multiple components, you wanted compound components instead.

Adjacent traps: render-props with stale closures (if children is recreated every render, useEffect deps churn — memoize the children fn or accept the cost), hooks that return JSX (defeats the point of composition; return state and actions instead), and combining both APIs by having the hook render JSX (now you cannot test the engine in isolation). The reference deliberately exports the engine as an implementation detail and the two APIs as the public surface.

Reference Implementation: Two APIs Over One Engine

A download engine exposed as both a useDownload hook and a Download render-prop component, sharing the same internal useDownloadEngine.

// The same downloader exposed two ways. The hook composes; the render-prop
// nests. Pick by API ergonomics, but both should share the underlying
// engine — duplicating logic is the failure mode this question catches.

type DownloadState =
  | { status: 'idle' }
  | { status: 'loading'; progress: number }
  | { status: 'done'; bytes: number }
  | { status: 'error'; message: string };

// The shared engine. Pure logic, no JSX. Both APIs delegate here.
function useDownloadEngine(url: string) {
  const [state, setState] = React.useState<DownloadState>({ status: 'idle' });

  const start = React.useCallback(() => {
    setState({ status: 'loading', progress: 0 });
    const controller = new AbortController();
    fetch(url, { signal: controller.signal })
      .then(async (response) => {
        const blob = await response.blob();
        setState({ status: 'done', bytes: blob.size });
      })
      .catch((error: Error) => {
        if (controller.signal.aborted) return;
        setState({ status: 'error', message: error.message });
      });
    return () => controller.abort();
  }, [url]);

  return { state, start };
}

// Hook API — composes top-down. Caller decides where to render.
export function useDownload(url: string) {
  return useDownloadEngine(url);
}

// Render-prop API — same engine. Caller controls JSX via children fn.
type DownloadRenderProps = {
  url: string;
  children: (api: ReturnType<typeof useDownloadEngine>) => React.ReactElement;
};

export function Download({ url, children }: DownloadRenderProps) {
  const api = useDownloadEngine(url);
  return children(api);
}

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

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.

Engine sharing
Render both APIs with the same URL and assert their state shapes match. The test catches the most common refactor regression: drift between the two APIs because someone added a field to the hook and forgot the render-prop.
Children stability
For the render-prop API, document whether the children function is expected to be stable. If not, internal effects must be tolerant of re-runs. Add a test that asserts children is called the expected number of times for the documented lifecycle.
Surface choice
Maintain a one-paragraph decision doc: when to expose a hook, when to expose a render-prop, when to switch to compound components. The interview signal is the principle, not the framework — the rule is "match the call-site shape that callers actually need".
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

describe('useDownload (hook API)', () => {
  it('exposes the engine state directly', () => {
    function Probe() {
      const { state } = useDownload('about:blank');
      return <span>{state.status}</span>;
    }
    render(<Probe />);
    expect(screen.getByText('idle')).toBeInTheDocument();
  });
});

describe('Download (render-prop API)', () => {
  it('passes the engine state through children fn', () => {
    render(
      <Download url="about:blank">
        {({ state }) => <span data-testid="render-prop">{state.status}</span>}
      </Download>
    );
    expect(screen.getByTestId('render-prop')).toHaveTextContent('idle');
  });
});

describe('shared engine', () => {
  it('both APIs produce the same shape', () => {
    let hookApi: ReturnType<typeof useDownload> | null = null;
    function HookProbe() {
      hookApi = useDownload('about:blank');
      return null;
    }
    let renderApi: ReturnType<typeof useDownload> | null = null;
    render(
      <>
        <HookProbe />
        <Download url="about:blank">
          {(api) => {
            renderApi = api;
            return <span />;
          }}
        </Download>
      </>
    );
    expect(Object.keys(hookApi!).sort()).toEqual(Object.keys(renderApi!).sort());
  });
});

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?