Skip to solution
mediumFrontend

Error Boundaries with RSC — why can't Server Components catch errors?

79 views
01

Understand the problem

Question presented to candidate: "Why does the error boundary in a React Server Components app have to be a Client Component?"

What a strong answer should cover:

  • An Error Boundary needs getDerivedStateFromError, componentDidCatch and state to hold the error. A Server Component has no state, no lifecycle and renders once — so it cannot be one.
  • Boundaries therefore live on the client, marked "use client", and they can still wrap Server Components rendered as their children, because composition crosses the boundary even though code does not.
  • When a Server Component throws on the server, the framework serialises the failure into the stream and the nearest client boundary renders its fallback.
  • In production the message is redacted. You get a digest — a hash to correlate with your server logs — not the original message, because a server error can contain a query, a path, or a secret.
  • That is a genuine debugging difference: server errors are found in your server logs, not in the browser console.
  • Recovery is different too. A client boundary cannot re-run a Server Component's render on its own — retrying needs a new request to the server, which is what a framework's reset does.
  • In Next.js the App Router convention is an error.tsx file, which must carry "use client" for exactly this reason.
  • Server-side errors that never reach a boundary — a route handler, a failed Server Action — need server-level observability instead.

Clarifying questions expected:

  • "Did the error happen during the server render, or after hydration on the client?" — different diagnosis entirely.
  • "Do we have server-side error reporting wired up?"

Code / implementation expected: Optional. Pointing out the "use client" on the boundary is the substance.

error-boundaryrsc
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The boundary that must be a Client Component, and a retry that actually retries
Run Playground
import React, { Component, useState } from "react";

// This playground has no RSC runtime, so the SERVER side below is annotated
// reference. What runs live is the client boundary itself — the half that is
// identical in an RSC app.

const SERVER_SIDE = `// app/report/page.jsx  — a Server Component, no "use client"
export default async function ReportPage() {
  const rows = await db.report.findMany();   // may throw, ON THE SERVER
  return (
    // The boundary is a CLIENT component, imported here and wrapping
    // Server Components as children. Composition crosses the boundary
    // even though code does not.
    <ErrorBoundary>
      <ReportTable rows={rows} />            {/* still a Server Component */}
    </ErrorBoundary>
  );
}

// app/report/error.jsx  — the Next.js App Router convention
"use client";                                //  REQUIRED: needs state
export default function Error({ error, reset }) {
  // In production, error.message is generic and error.digest is a hash
  // you match against the server logs. The real text never leaves the server.
  return (
    <div>
      <p>Something went wrong.</p>
      <code>digest: {error.digest}</code>
      {/* reset() RE-REQUESTS the segment from the server. A setState-only
          retry would re-render the same failed payload forever. */}
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}`;

// ── The live half: a boundary needs state and a SECOND render ──────────────
class ErrorBoundary extends Component {
  state = { error: null };
  static getDerivedStateFromError(error) { return { error }; }
  render() {
    if (this.state.error) {
      return (
        <div style={{ border: "1px solid #e0b4b4", background: "#fdf0f0", borderRadius: 8, padding: 12 }}>
          <strong style={{ color: "#a33" }}>fallback rendered</strong>
          <div style={{ fontSize: 13 }}>{this.state.error.message}</div>
          {/* Clearing state only helps because the CHILD is client-side and
              will genuinely re-run. For a server payload it would not. */}
          <button onClick={() => this.setState({ error: null })}>reset boundary state</button>
        </div>
      );
    }
    return this.props.children;
  }
}

function Child({ broken }) {
  if (broken) throw new Error("the report query failed");
  return <div style={{ background: "#f2f9f2", border: "1px solid #cde3cd", borderRadius: 8, padding: 12 }}>report rendered fine</div>;
}

export default function App() {
  const [broken, setBroken] = useState(false);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 620 }}>
      <button onClick={() => setBroken((b) => !b)} style={{ marginBottom: 10 }}>
        {broken ? "fix the child" : "break the child"}
      </button>

      <ErrorBoundary key={String(broken)}>
        <Child broken={broken} />
      </ErrorBoundary>

      <p style={{ fontSize: 13, color: "#666" }}>
        The boundary above holds the error in <code>state</code> and renders a
        different tree — two things a Server Component cannot do, which is the
        whole answer to the question. Below is what the server half looks like.
      </p>

      <pre style={{ background: "#f6f6f8", border: "1px solid #ddd", borderRadius: 8,
                    padding: 12, fontSize: 12, overflowX: "auto", whiteSpace: "pre-wrap" }}>
{SERVER_SIDE}
      </pre>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 69 of 119 decoded in the React.js track. One more won't hurt.

Back to track