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

Implement Suspense loading boundaries in a product component

Use Suspense loading boundaries to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.

Answer Strategy

Suspense is the React feature most often described wrong. The interview-grade answer is one sentence: a child can throw a Promise, and the nearest Suspense boundary catches it and renders the fallback until the Promise settles. Everything else (streaming, server components, data libraries, transitions) is composition on top of that contract.

The interview win is naming why this is better than a useEffect+isLoading pattern. With Suspense, the "loading" state is part of the rendered tree, not a branch inside the component. Loading states colocate with the data dependency, not with the component that needs the data. Multiple boundaries can stagger fallbacks (the shell loads instantly, the inner card streams in). And errors flow upward to an error boundary instead of polluting every consumer with try/catch.

Volunteer the failures. A resource created during render (instead of in a state initializer) re-fires the fetch every render, and Suspense never settles. A Suspense boundary placed too high makes the entire page feel like a blank screen; placed too low, every loading shimmer flickers. Without an error boundary, a thrown error from inside Suspense crashes the whole tree. The reference shows the canonical layout: error boundary outside, Suspense inside, resource owned by useState.

Reference Implementation: Suspense Resource With Error Boundary

A minimal Suspense-compatible resource plus a profile component that uses Suspense for loading and an error boundary for failure. Production code would replace createResource with the data library that owns caching.

type Resource<T> = {
  read: () => T;
};

// Minimal Suspense-compatible resource. In production you would use a data
// library (Relay, TanStack Query, Next.js cache) instead of this primitive,
// but the interview is about the *contract*: a function that either returns
// a value or throws a Promise.
function createResource<T>(promise: Promise<T>): Resource<T> {
  let status: 'pending' | 'success' | 'error' = 'pending';
  let value: T;
  let error: unknown;

  const tracking = promise.then(
    (next) => {
      status = 'success';
      value = next;
    },
    (err) => {
      status = 'error';
      error = err;
    }
  );

  return {
    read() {
      if (status === 'pending') throw tracking; // Suspense catches the thrown promise.
      if (status === 'error') throw error;
      return value;
    },
  };
}

type Profile = { name: string; bio: string };

function ProfileBody({ resource }: { resource: Resource<Profile> }) {
  // The component looks synchronous. The Suspense boundary above handles
  // pending state via a fallback; the error boundary above handles failures.
  // This is the React idiom: "describe the rendered tree, not the timeline".
  const profile = resource.read();
  return (
    <article>
      <h2>{profile.name}</h2>
      <p>{profile.bio}</p>
    </article>
  );
}

type ProfilePageProps = {
  fetchProfile: () => Promise<Profile>;
};

export function ProfilePage({ fetchProfile }: ProfilePageProps) {
  // The resource is created in a state initializer so it survives re-renders.
  // Without this, every render starts a new fetch and Suspense never settles.
  const [resource] = React.useState(() => createResource(fetchProfile()));

  return (
    <ProfileErrorBoundary fallback={<p role="alert">Profile unavailable.</p>}>
      <React.Suspense fallback={<p role="status">Loading profile...</p>}>
        <ProfileBody resource={resource} />
      </React.Suspense>
    </ProfileErrorBoundary>
  );
}

type ProfileErrorBoundaryProps = {
  fallback: React.ReactNode;
  children: React.ReactNode;
};

class ProfileErrorBoundary extends React.Component<
  ProfileErrorBoundaryProps,
  { error: unknown }
> {
  state = { error: null as unknown };

  static getDerivedStateFromError(error: unknown) {
    return { error };
  }

  componentDidCatch(error: unknown) {
    // Production code would also report to an observability sink here.
    if (typeof console !== 'undefined') console.error(error);
  }

  render() {
    if (this.state.error !== null) return this.props.fallback;
    return this.props.children;
  }
}

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.

Pending
Render with a never-resolving promise; the Suspense fallback must be visible. Use role=status so screen readers announce the wait.
Success
Render with a resolved promise; assert the profile content appears. Resource creation must not re-fire the fetch on re-render.
Failure
Render with a rejecting promise; the error boundary fallback must replace the body. Cover that subsequent renders stay on the fallback rather than re-throwing.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';

describe('ProfilePage', () => {
  it('shows the loading fallback while the resource is pending', () => {
    let resolve: (value: any) => void = () => {};
    const fetchProfile = () => new Promise<{ name: string; bio: string }>((res) => {
      resolve = res;
    });
    render(<ProfilePage fetchProfile={fetchProfile} />);
    expect(screen.getByRole('status')).toHaveTextContent(/loading profile/i);
    resolve({ name: 'Ada', bio: 'Pioneer' });
  });

  it('renders the profile after the promise resolves', async () => {
    const fetchProfile = async () => ({ name: 'Ada', bio: 'Pioneer' });
    render(<ProfilePage fetchProfile={fetchProfile} />);
    expect(await screen.findByText('Ada')).toBeInTheDocument();
  });

  it('renders the error fallback when the promise rejects', async () => {
    const fetchProfile = async () => { throw new Error('boom'); };
    render(<ProfilePage fetchProfile={fetchProfile} />);
    expect(await screen.findByRole('alert')).toHaveTextContent('Profile unavailable.');
  });
});

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?