Advanced (Diperbarui: 2/6/2026)

React Error Boundary dengan Claude Code: panduan implementasi aman

Implementasi React Error Boundary dengan Claude Code: cakupan, penempatan, reset, logging aman, test, dan prompt.

React Error Boundary dengan Claude Code: panduan implementasi aman

Kegagalan frontend paling berbahaya bukan satu grafik yang rusak. Yang berbahaya adalah error kecil saat render membuat seluruh aplikasi React menjadi layar putih. Jika Anda hanya meminta Claude Code “tambahkan error handling”, hasilnya bisa berupa beberapa try/catch, sementara error yang muncul saat render React tetap tidak terisolasi.

Panduan ini menjelaskan cara memakai Claude Code untuk mengimplementasikan React Error Boundary secara aman. Error Boundary adalah batas di dalam tree komponen: ketika child component melempar error tak terduga saat render, boundary menampilkan fallback UI alih-alih membiarkan seluruh tampilan runtuh. Boundary tidak memperbaiki akar masalah, tetapi membatasi dampak, memberi user langkah berikutnya, dan menghasilkan log yang berguna untuk investigasi.

Masa menguji pola ini pada dashboard admin. Prompt pertama membungkus seluruh app dengan satu boundary. Layar putih memang berkurang, tetapi satu revenue chart yang rusak ikut menyembunyikan settings dan billing, dan log berisi URL dengan email di query string. Setelah prompt mencantumkan penempatan, aturan reset, redaksi PII, dan test, diff dari Claude Code jauh lebih mudah direview.

Tetapkan Fakta Dari Dokumentasi React

Sebelum meminta kode, tetapkan dulu fakta teknisnya. Referensi resmi React untuk Component menjelaskan pembagian peran: static getDerivedStateFromError dipakai untuk berpindah ke state fallback, sedangkan componentDidCatch dipakai untuk side effect seperti logging. Dokumentasi resmi lint error-boundaries juga menegaskan bahwa try/catch biasa di sekitar JSX bukan alat yang tepat untuk error render.

Batas pentingnya adalah: Error Boundary tidak menangkap semua error. Ia menangkap error tak terduga dari descendant component saat rendering, lifecycle, atau kode yang berjalan sebagai bagian dari render. Ia tidak menangkap click handler, timer, rejection Promise biasa, server-side rendering, atau error di fallback milik boundary itu sendiri.

Lokasi errorDitangkap Error BoundaryPenanganan produksi
Child component error saat renderYaTampilkan fallback UI dan kirim log yang sudah diredaksi
Error di hook atau memo saat renderBiasanya yaValidasi failure yang expected; kirim exception tak terduga ke boundary
Button click atau form submit handlerTidakTangani dengan try/catch lokal, lalu rethrow lewat state jika perlu
setTimeout, requestAnimationFrame, atau Promise biasaTidakTangani Promise secara eksplisit dan sediakan retry
Server-side renderingTidakGunakan error page framework, server log, dan HTTP status
Fallback milik boundary ikut errorTidakBuat fallback sederhana dan pasang boundary di level lebih tinggi
flowchart TD
  A["Child component melempar error saat render"] --> B["Error Boundary terdekat"]
  B --> C["Fallback UI untuk user"]
  B --> D["Laporan error yang diredaksi"]
  E["Click handler atau setTimeout gagal"] --> F["Tangani lokal atau rethrow lewat state"]
  F --> B

Pisahkan Boundary Level Route Dan Level Komponen

Lebih banyak boundary tidak otomatis berarti arsitektur lebih baik. Satu boundary untuk seluruh app terlalu luas, tetapi boundary di setiap tombol membuat halaman penuh fragmen fallback. Minta Claude Code membedakan route-level boundary dan component-level boundary.

Route-level boundary melindungi satu tanggung jawab layar: dashboard, settings, billing, editor, search, atau audit log admin. Boundary ini harus reset saat navigasi berubah agar status gagal dari route lama tidak ikut muncul di layar berikutnya.

Component-level boundary melindungi region independen di dalam halaman. Kandidat yang baik adalah revenue chart, notification panel, Markdown preview, recommendation widget, third-party embed, atau JSON viewer besar. Kandidat yang buruk adalah input biasa, tombol submit, heading, dan icon. Kasus itu seharusnya ditangani oleh validation dan state UI normal.

Gunakan tiga pertanyaan: jika region ini gagal, apakah user masih bisa bekerja; apakah region ini bisa retry, reload, atau reset sendiri; apakah feature name di log membuat diagnosis lebih mudah. Ini sejalan dengan strategi testing Claude Code: unit yang bisa dicoba ulang oleh user sebaiknya juga menjadi unit yang bisa dites.

Komponen Error Boundary Yang Bisa Dicopy

Shared Error Boundary tetap berupa class component. Bagian aplikasi lainnya bisa tetap memakai function component; hanya wrapper ini yang memakai lifecycle method React untuk error boundary.

// src/components/error-boundary/ErrorBoundary.tsx
import { Component, ErrorInfo, ReactNode } from "react";

export type ErrorBoundaryFallbackProps = {
  error: Error;
  resetErrorBoundary: () => void;
};

type ErrorBoundaryProps = {
  children: ReactNode;
  fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode);
  onError?: (error: Error, info: ErrorInfo) => void;
  onReset?: () => void;
  resetKeys?: ReadonlyArray<unknown>;
};

type ErrorBoundaryState = {
  error: Error | null;
};

function normalizeError(value: unknown): Error {
  if (value instanceof Error) return value;
  return new Error(typeof value === "string" ? value : "Unknown render error");
}

function changedArray(
  previous: ReadonlyArray<unknown> = [],
  next: ReadonlyArray<unknown> = [],
): boolean {
  return (
    previous.length !== next.length ||
    previous.some((item, index) => !Object.is(item, next[index]))
  );
}

export class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { error: null };

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

  componentDidCatch(error: Error, info: ErrorInfo) {
    this.props.onError?.(normalizeError(error), info);
  }

  componentDidUpdate(previousProps: ErrorBoundaryProps) {
    if (
      this.state.error &&
      changedArray(previousProps.resetKeys, this.props.resetKeys)
    ) {
      this.resetErrorBoundary();
    }
  }

  resetErrorBoundary = () => {
    this.props.onReset?.();
    this.setState({ error: null });
  };

  render() {
    if (!this.state.error) return this.props.children;

    if (typeof this.props.fallback === "function") {
      return this.props.fallback({
        error: this.state.error,
        resetErrorBoundary: this.resetErrorBoundary,
      });
    }

    if (this.props.fallback) return this.props.fallback;

    return (
      <section role="alert" aria-labelledby="error-boundary-title">
        <h2 id="error-boundary-title">Something went wrong</h2>
        <p>Please retry. If the problem continues, contact support.</p>
        <button type="button" onClick={this.resetErrorBoundary}>
          Try again
        </button>
      </section>
    );
  }
}

fallback bisa berupa node statis atau function. Bentuk function lebih berguna karena menerima error dan resetErrorBoundary. Namun jangan tampilkan error.stack, response API mentah, atau pesan debug internal ke user. UI cukup berisi penjelasan pendek, aksi yang aman, dan bila perlu referensi support.

Fallback UI, Reset, Dan Retry

Fallback UI bukan dump debug; ia adalah UI produk. Ia harus menjelaskan bagian mana yang berhenti, apakah data user berubah, dan aksi apa yang aman. Untuk chunk loading error setelah deploy, reload seluruh app bisa membantu. Untuk widget biasa, retry hanya pada region tersebut biasanya lebih baik.

// src/components/error-boundary/AppErrorFallback.tsx
import type { ErrorBoundaryFallbackProps } from "./ErrorBoundary";

export function AppErrorFallback({
  error,
  resetErrorBoundary,
}: ErrorBoundaryFallbackProps) {
  const reloadRecommended =
    /ChunkLoadError|Loading chunk|dynamically imported module/i.test(
      error.message,
    );

  return (
    <section
      role="alert"
      aria-labelledby="app-error-title"
      className="error-fallback"
    >
      <div>
        <p className="error-fallback__eyebrow">This section stopped working</p>
        <h2 id="app-error-title">We could not render this part of the page.</h2>
        <p>
          Your account data was not changed. Retry this section first, then
          reload the app if the same message appears again.
        </p>
      </div>

      <div className="error-fallback__actions">
        <button type="button" onClick={resetErrorBoundary}>
          Try again
        </button>
        {reloadRecommended ? (
          <button type="button" onClick={() => window.location.reload()}>
            Reload app
          </button>
        ) : null}
      </div>
    </section>
  );
}
/* src/components/error-boundary/error-fallback.css */
.error-fallback {
  border: 1px solid #d7dde8;
  border-radius: 8px;
  padding: 16px;
  background: #fff;
  color: #1f2937;
}

.error-fallback__eyebrow {
  margin: 0 0 4px;
  color: #6b7280;
  font-size: 0.875rem;
}

.error-fallback h2 {
  margin: 0 0 8px;
  font-size: 1.125rem;
}

.error-fallback__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}

Bug retry yang umum adalah hanya membersihkan state boundary, tetapi input yang rusak tetap sama. Jika props, cache, atau route state yang sama langsung melempar lagi, tombol retry terlihat gagal. Gunakan resetKeys untuk route key, filter, user id, refresh counter, atau versi data.

Contoh Route Dan Komponen

Dengan React Router, boundary route bisa berupa wrapper tipis. Contoh ini reset memakai location.key dan mengirim feature name ke log. Framework seperti Next.js dan Remix punya file error per route, tetapi prinsip desainnya sama: reset saat navigasi dan isolasi failure per layar.

// src/AppRoutes.tsx
import { lazy, ReactNode, Suspense } from "react";
import {
  createBrowserRouter,
  RouterProvider,
  useLocation,
} from "react-router-dom";
import { ErrorBoundary } from "./components/error-boundary/ErrorBoundary";
import { AppErrorFallback } from "./components/error-boundary/AppErrorFallback";
import { currentErrorContext, reportReactError } from "./lib/error-reporting";
import { Layout } from "./routes/Layout";

const DashboardPage = lazy(() => import("./routes/DashboardPage"));
const SettingsPage = lazy(() => import("./routes/SettingsPage"));

function RouteBoundary({
  children,
  feature,
}: {
  children: ReactNode;
  feature: string;
}) {
  const location = useLocation();

  return (
    <ErrorBoundary
      resetKeys={[location.key]}
      fallback={(props) => <AppErrorFallback {...props} />}
      onError={(error, info) => {
        void reportReactError(
          error,
          info.componentStack,
          currentErrorContext(feature),
        );
      }}
    >
      <Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
    </ErrorBoundary>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        path: "dashboard",
        element: (
          <RouteBoundary feature="dashboard">
            <DashboardPage />
          </RouteBoundary>
        ),
      },
      {
        path: "settings",
        element: (
          <RouteBoundary feature="settings">
            <SettingsPage />
          </RouteBoundary>
        ),
      },
    ],
  },
]);

export function AppRoutes() {
  return <RouterProvider router={router} />;
}

Component-level boundary cocok untuk region yang dapat pulih secara independen: chart, Markdown preview, recommendation panel, third-party embed, dan JSON viewer. Jangan pasang pada setiap form field. Payment decline, validation error, dan session expired adalah state produk normal.

Error Async Dan Event Handler

Error Boundary tidak otomatis menangkap click handler dan async failure biasa. Failure yang expected tetap di UI lokal: validation field, autentikasi, pembayaran. Exception yang unexpected bisa disimpan dalam state lalu dilempar pada render berikutnya agar boundary terdekat menangkapnya.

// src/components/error-boundary/useAsyncBoundary.ts
import { useCallback, useState } from "react";

function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  return new Error(typeof value === "string" ? value : "Unknown async error");
}

export function useAsyncBoundary() {
  const [error, setError] = useState<Error | null>(null);

  if (error) {
    throw error;
  }

  return useCallback((value: unknown) => {
    setError(toError(value));
  }, []);
}
// src/components/settings/SaveButton.tsx
import { useState } from "react";
import { useAsyncBoundary } from "../error-boundary/useAsyncBoundary";

type SaveButtonProps = {
  onSave: () => Promise<void>;
};

export function SaveButton({ onSave }: SaveButtonProps) {
  const [pending, setPending] = useState(false);
  const throwToBoundary = useAsyncBoundary();

  async function handleClick() {
    setPending(true);

    try {
      await onSave();
    } catch (error) {
      throwToBoundary(error);
    } finally {
      setPending(false);
    }
  }

  return (
    <button type="button" disabled={pending} onClick={handleClick}>
      {pending ? "Saving..." : "Save"}
    </button>
  );
}

Tulis di prompt bahwa tidak semua async failure boleh dikirim ke boundary. Response 400, field invalid, rate limit, atau session expired perlu UI lokal. Boundary dipakai untuk exception tak terduga, response rusak, dan asumsi render yang bisa membuat halaman jatuh.

Logging Tanpa Membocorkan PII

PII adalah informasi yang dapat mengidentifikasi seseorang: email, nomor telepon, nama, alamat, token, nomor kartu, atau teks bebas dari support form. componentDidCatch adalah tempat yang baik untuk melaporkan client error, tetapi payload harus dibatasi dan diredaksi.

Log yang disarankan: feature, release, route pathname, error name, message yang diredaksi, stack, dan componentStack. Jangan kirim query string, nilai form, cookies, Authorization header, response API mentah, atau full URL.

// src/lib/error-reporting.ts
type ClientErrorContext = {
  route: string;
  release: string;
  feature?: string;
  userHash?: string;
};

const REDACTIONS: Array<[RegExp, string]> = [
  [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]"],
  [/\b(?:\d[ -]*?){13,19}\b/g, "[redacted-number]"],
  [/\b(token|secret|password|authorization)=([^&\s]+)/gi, "$1=[redacted]"],
  [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]"],
];

export function redactText(value: string | undefined): string | undefined {
  if (!value) return value;

  return REDACTIONS.reduce(
    (text, [pattern, replacement]) => text.replace(pattern, replacement),
    value,
  );
}

export function currentErrorContext(feature?: string): ClientErrorContext {
  const env = (import.meta as unknown as {
    env?: Record<string, string | undefined>;
  }).env;

  return {
    route: typeof window === "undefined" ? "server" : window.location.pathname,
    release: env?.VITE_APP_VERSION ?? "dev",
    feature,
  };
}

export async function reportReactError(
  error: Error,
  componentStack: string | undefined,
  context: ClientErrorContext,
) {
  const payload = {
    name: redactText(error.name) ?? "Error",
    message: redactText(error.message) ?? "Unknown error",
    stack: redactText(error.stack),
    componentStack: redactText(componentStack),
    route: context.route,
    release: context.release,
    feature: context.feature,
    userHash: context.userHash,
  };

  const body = JSON.stringify(payload);

  if (typeof navigator !== "undefined" && navigator.sendBeacon) {
    const sent = navigator.sendBeacon(
      "/api/client-errors",
      new Blob([body], { type: "application/json" }),
    );
    if (sent) return;
  }

  await fetch("/api/client-errors", {
    method: "POST",
    headers: { "content-type": "application/json" },
    credentials: "omit",
    keepalive: true,
    body,
  });
}

Redaksi lagi di server. Redaksi client membantu, tetapi bukan batas kepatuhan. Minta Claude Code menerapkan dua lapisan dan hanya memakai user identifier yang sudah di-hash jika perlu korelasi support.

Test Dan Command Verifikasi

Error Boundary hanya berguna ketika sesuatu rusak, jadi test path yang rusak. Minimum: fallback muncul, onError dipanggil, dan retry bisa reset.

// src/components/error-boundary/ErrorBoundary.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { ReactNode, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "./ErrorBoundary";

function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
  if (shouldThrow) {
    throw new Error("profile widget crashed");
  }

  return <p>Profile loaded</p>;
}

function RetryHarness({ onError }: { onError: ReturnType<typeof vi.fn> }) {
  const [broken, setBroken] = useState(true);

  return (
    <ErrorBoundary
      onError={onError}
      fallback={({ resetErrorBoundary }) => (
        <button
          type="button"
          onClick={() => {
            setBroken(false);
            resetErrorBoundary();
          }}
        >
          Retry profile
        </button>
      )}
    >
      <Bomb shouldThrow={broken} />
    </ErrorBoundary>
  );
}

function StaticFallback({ children }: { children: ReactNode }) {
  return (
    <ErrorBoundary fallback={<p>Could not load this panel.</p>}>
      {children}
    </ErrorBoundary>
  );
}

describe("ErrorBoundary", () => {
  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
  });

  afterEach(() => {
    consoleErrorSpy.mockRestore();
  });

  it("renders fallback UI when a child throws", () => {
    render(
      <StaticFallback>
        <Bomb shouldThrow />
      </StaticFallback>,
    );

    expect(screen.getByText("Could not load this panel.")).toBeInTheDocument();
  });

  it("calls onError with the thrown error and component stack", () => {
    const onError = vi.fn();

    render(<RetryHarness onError={onError} />);

    expect(onError).toHaveBeenCalledTimes(1);
    expect(onError.mock.calls[0][0].message).toBe("profile widget crashed");
    expect(onError.mock.calls[0][1].componentStack).toContain("Bomb");
  });

  it("can reset and render children again", async () => {
    const user = userEvent.setup();
    const onError = vi.fn();

    render(<RetryHarness onError={onError} />);
    await user.click(screen.getByRole("button", { name: "Retry profile" }));

    expect(screen.getByText("Profile loaded")).toBeInTheDocument();
  });
});
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
npm run typecheck
npx vitest run src/components/error-boundary/ErrorBoundary.test.tsx
npm run build

Prompt Aman Untuk Claude Code

Add React Error Boundaries to this React + TypeScript app.

Constraints:
- Follow the official React Error Boundary model.
- Catch render errors from descendants, but handle event handlers and ordinary async failures separately.
- Implement a shared ErrorBoundary class, user-facing fallback UI, and reportReactError with PII redaction.
- Route-level boundaries must reset on navigation through resetKeys.
- Component-level boundaries should only wrap independent regions such as DashboardChart, MarkdownPreview, and RecommendationPanel.
- Do not log error.stack, query strings, form values, Authorization headers, cookies, or raw API responses without redaction.
- Add Vitest + Testing Library coverage for fallback UI, onError, and retry reset.
- Run npm run typecheck, npx vitest run, and npm run build, then report the results.

Read the existing routing, logging, and CSS conventions first. Keep the diff minimal.

Prompt review:

Review this diff only from the Error Boundary perspective.
List issues with boundary placement, async errors that are not caught, PII leakage, missing resetKeys, fallback accessibility, and missing tests.
Do not change code. Return file names and line numbers.

Use Case Dan Pitfall

Use case pertama adalah dashboard SaaS. Bungkus revenue chart, active users table, notification panel, dan third-party embed secara terpisah. Bug dari chart library tidak boleh memblokir settings atau billing. Di log gunakan feature seperti dashboard.revenue-chart.

Use case kedua adalah editor konten. Markdown preview, image preview, dan AI summary panel lebih mudah rusak. Body editor dan save button adalah area kerja utama. Preview bisa memakai boundary; kegagalan save harus ditampilkan di event handler.

Use case ketiga adalah ecommerce atau signup. Card decline, stock shortage, dan validation failure bukan boundary error, melainkan state produk yang expected. Recommendation module, campaign banner, dan review widget bisa diisolasi.

Use case keempat adalah audit log admin. JSON viewer besar bisa throw saat formatting. Bungkus viewer saja, bukan seluruh halaman, agar operator tetap bisa mengganti filter, export CSV, atau memeriksa user lain.

Pitfall yang sering muncul: mengandalkan try/catch di sekitar JSX, mengirim semua async failure ke boundary, mencatat full URL dengan query string, menampilkan stack trace di UI, reset tanpa mengubah input yang rusak, dan memasang boundary terlalu kecil sampai halaman penuh fallback. Untuk tim, jadikan prompt implementasi dan review sebagai Claude Code command yang bisa dipakai ulang. Jika ingin menstandarkan pola ini di codebase, hubungkan langkah lanjut ke training dan dukungan implementasi Claude Code.

Ringkasan

Error Boundary bukan universal exception handler. Ia adalah boundary khusus React untuk render failure, dengan fallback UI dan logging aman. Saat memakai Claude Code, tulis cakupan yang ditangkap, penempatan route-level dan component-level, perilaku reset, kebijakan PII, tests, dan command verifikasi dalam prompt.

Dalam uji praktik di dashboard, menentukan resetKeys dan aturan redaksi sebelum meminta kode membuat review lebih cepat. App tidak lagi jatuh total ketika satu widget crash, dan log tetap berguna tanpa mengekspos data user.

#Claude Code #React #Error Boundary #error handling #TypeScript
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.