ข้ามไปยังเนื้อหา

Error Boundaries

ถ้า component throw ระหว่าง render React — โดยตั้งใจ — จะ unmount ทั้ง tree แทนที่จะแสดง UI ที่เสียหาย error boundary คือ component ที่จับ error แบบนั้นใน subtree ของตัวเอง เพื่อให้ความล้มเหลวใน widget เดียวแสดง fallback ตรงนั้น แทนที่จะทำทั้งหน้าล่ม

error boundary ยังต้องเขียนเป็น class component เพราะพึ่ง lifecycle method สองตัวที่ไม่มี hook เทียบเท่า:

import { Component } from "react";
class ErrorBoundary extends Component {
state = { error: null };
// Render phase: return new state to show a fallback on the next render.
static getDerivedStateFromError(error) {
return { error };
}
// Commit phase: side effect — log the error to your reporting service.
componentDidCatch(error, info) {
reportError(error, info.componentStack);
}
render() {
if (this.state.error) return this.props.fallback;
return this.props.children;
}
}

ใน production ส่วนใหญ่ใช้ library เล็ก ๆ ชื่อ react-error-boundary ที่ห่อของแบบนี้และเพิ่มกลไก reset — แต่กลไกข้างในก็คือด้านบนเป๊ะ ๆ

<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<Widget /> {/* if Widget throws while rendering, the fallback shows here */}
</ErrorBoundary>

ตรงนี้คนเข้าใจผิดบ่อย error boundary จับ error ที่เกิด ระหว่าง rendering (และใน lifecycle method กับ constructor) ของ component ที่อยู่ ใต้ boundary แต่ ไม่ จับ:

  • event handler — error ใน onClick เป็น JS error ปกติ; ใช้ try/catch
  • asynchronous code — promise ที่ reject หรือ callback ของ setTimeout รันนอก render ของ React ดังนั้นไม่มี boundary ไหนเห็น
  • error ใน boundary เอง — boundary จับเฉพาะ error ที่อยู่ ใต้ ตัวเอง
  • server-side rendering error — จัดการแยกต่างหาก
flowchart TB
  eb["Error Boundary"] --> child["child render"]
  child -->|throw ระหว่าง render| caught["จับได้ → แสดง fallback"]
  handler["onClick throw"] -.ไม่ถูกจับ.-> handler2["ใช้ try/catch"]
  async["promise reject / setTimeout"] -.ไม่ถูกจับ.-> async2["จัดการเอง"]
error boundary จับอะไร

Suspense และ error boundary เสริมกัน: Suspense จัดการ state loading (child กำลังรอ) ส่วน error boundary จัดการ state failure (child throw — รวมถึง promise ที่ reject ซึ่งอ่านด้วย use()) รวมกันได้ทั้งสาม state ของ async UI แบบ declarative:

<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Spinner />}>
<Profile /> {/* loading → Spinner, error → Error, success → Profile */}
</Suspense>
</ErrorBoundary>

เมื่อ use() อ่าน promise ที่ reject React จะ propagate error นั้นไปยัง error boundary ที่ใกล้ที่สุด — ดังนั้น boundary เดียวกันที่จัดการ render crash ก็จัดการ data-fetch failure ด้วย

ตั้งแต่ React 19 createRoot (และ hydrateRoot) รับ option ที่ให้คุณสังเกต error ทุกตัวที่ React จัดการ — แทนที่จะพึ่งแค่ output ของ console.error หรือ error boundary ระดับบนสุดตัวเดียว:

“onCaughtError: Callback called when React catches an error in an Error Boundary. Called with the error caught by the Error Boundary, and an errorInfo object containing the componentStack.” — react.dev, createRoot

“onUncaughtError: Callback called when an error is thrown and not caught by an Error Boundary.” — เอกสารเดียวกัน

“onRecoverableError: Callback called when React automatically recovers from errors.” — เอกสารเดียวกัน (นี่คือ hook สำหรับ hydration mismatch ที่ React ซ่อมเองเงียบ ๆ)

src/__tests__/rootErrorOptions.test.tsx
import { Component, type ReactNode } from 'react';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
// This file doesn't import @testing-library/react (whose import has the side
// effect of enabling React's test-act environment), so it's set explicitly.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class Boundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) return <p>fallback</p>;
return this.props.children;
}
}
function ThrowsCaught(): ReactNode {
throw new Error('caught-me');
}
function ThrowsUncaught(): ReactNode {
throw new Error('uncaught-me');
}
test('onCaughtError fires with errorInfo.componentStack when a boundary catches', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const calls: { message: string; hasStack: boolean }[] = [];
const root = createRoot(container, {
onCaughtError(error, errorInfo) {
calls.push({
message: (error as Error).message,
hasStack: typeof errorInfo.componentStack === 'string' && errorInfo.componentStack.includes('ThrowsCaught'),
});
},
onUncaughtError() {},
});
act(() => {
root.render(
<Boundary>
<ThrowsCaught />
</Boundary>,
);
});
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe('caught-me');
expect(calls[0].hasStack).toBe(true); // componentStack names ThrowsCaught
root.unmount();
container.remove();
});
test('onUncaughtError fires when no boundary catches the error', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
const uncaught: string[] = [];
const root = createRoot(container, {
onCaughtError() {},
onUncaughtError(error) {
uncaught.push((error as Error).message);
},
});
// Deliberately not wrapped in act(): act() forces a synchronous rethrow to
// the caller for its own flush-and-check semantics, which is a test-harness
// artifact, not what root.render does in a real app. Calling render()
// directly proves the real, documented behavior: onUncaughtError receives
// the error asynchronously and render() does not throw to its caller.
root.render(<ThrowsUncaught />);
await new Promise((r) => setTimeout(r, 0));
expect(uncaught).toEqual(['uncaught-me']);
root.unmount();
container.remove();
});
✓ onCaughtError fires with errorInfo.componentStack when a boundary catches
✓ onUncaughtError fires when no boundary catches the error

การต่อสายพวกนี้ที่ entry point ของแอปคือของทดแทนยุคใหม่ของการหวังว่า error จะโผล่ใน console.error:

const root = createRoot(document.getElementById("root"), {
onCaughtError(error, errorInfo) {
reportToMonitoring(error, errorInfo.componentStack);
},
onUncaughtError(error, errorInfo) {
reportToMonitoring(error, errorInfo.componentStack, { severity: "fatal" });
},
onRecoverableError(error, errorInfo) {
reportToMonitoring(error, errorInfo.componentStack, { severity: "hydration" });
},
});

state ของ error boundary เอง (this.state.error) เคลียร์เมื่อคุณเคลียร์เท่านั้น — React ไม่ retry subtree ที่ crash ให้อัตโนมัติแค่เพราะ props เปลี่ยน resetKeys ของ library react-error-boundary คือ pattern มาตรฐานสำหรับ “เคลียร์ error เมื่อสิ่งที่เป็นสาเหตุเปลี่ยนไปจริง ๆ”; คุณทำเองด้วย componentDidUpdate ได้:

src/__tests__/errorBoundaryResetKeys.test.tsx
import { Component, useState, type ReactNode } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
// Remember the resetKeys array from props; if we're currently showing an
// error AND any resetKey changed since the last render, clear the error and
// try the children again.
class ResettableBoundary extends Component<
{ children: ReactNode; resetKeys: unknown[] },
{ error: Error | null }
> {
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidUpdate(prevProps: { resetKeys: unknown[] }) {
if (this.state.error && prevProps.resetKeys.some((k, i) => k !== this.props.resetKeys[i])) {
this.setState({ error: null });
}
}
render() {
if (this.state.error) return <p data-testid="fallback">error: {this.state.error.message}</p>;
return this.props.children;
}
}
function Widget({ broken }: { broken: boolean }) {
if (broken) throw new Error('widget-broken');
return <p data-testid="widget-ok">ok</p>;
}
function App() {
const [broken, setBroken] = useState(true);
return (
<div>
<button data-testid="fix" onClick={() => setBroken(false)}>fix</button>
<ResettableBoundary resetKeys={[broken]}>
<Widget broken={broken} />
</ResettableBoundary>
</div>
);
}
test('resetKeys-style pattern: changing a resetKey after fixing the underlying cause clears the boundary', () => {
render(<App />);
expect(screen.getByTestId('fallback').textContent).toBe('error: widget-broken');
// Fixing the cause AND changing the resetKey (broken flips to false) in
// the same update clears the boundary and retries the children.
fireEvent.click(screen.getByTestId('fix'));
expect(screen.queryByTestId('fallback')).toBeNull();
expect(screen.getByTestId('widget-ok')).toBeTruthy();
});
test('resetKeys-style pattern: re-rendering with the SAME resetKeys does not clear a live error', () => {
function StaticApp() {
return (
<ResettableBoundary resetKeys={['same']}>
<Widget broken={true} />
</ResettableBoundary>
);
}
const { rerender } = render(<StaticApp />);
expect(screen.getByTestId('fallback')).toBeTruthy();
rerender(<StaticApp />);
// resetKeys didn't change, so the boundary must not clear itself.
expect(screen.getByTestId('fallback')).toBeTruthy();
});
✓ resetKeys-style pattern: changing a resetKey after fixing the underlying cause clears the boundary
✓ resetKeys-style pattern: re-rendering with the SAME resetKeys does not clear a live error
src/__tests__/eventHandlerNotCaught.test.tsx
import { Component, type ReactNode } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
class EBoundary extends Component<{ children: ReactNode }, { error: boolean }> {
state = { error: false };
static getDerivedStateFromError() {
return { error: true };
}
render() {
if (this.state.error) return <p data-testid="eb-fallback">fallback</p>;
return this.props.children;
}
}
function ClickBoom() {
return (
<button
data-testid="boom-btn"
onClick={() => {
throw new Error('event-handler-boom');
}}
>
click
</button>
);
}
test('an error boundary does NOT catch a throw from an event handler', () => {
render(
<EBoundary>
<ClickBoom />
</EBoundary>,
);
// React's event system doesn't route a handler's throw through any error
// boundary — it surfaces as a plain uncaught/global error, same as any
// other JS throw outside of React's render.
let globalMessage = '';
const onUncaught = (e: ErrorEvent) => {
globalMessage = e.error?.message ?? e.message;
};
window.addEventListener('error', onUncaught);
try {
fireEvent.click(screen.getByTestId('boom-btn'));
} finally {
window.removeEventListener('error', onUncaught);
}
expect(globalMessage).toContain('event-handler-boom');
// The boundary never activated — the button (not a fallback) is still there.
expect(screen.queryByTestId('eb-fallback')).toBeNull();
expect(screen.getByTestId('boom-btn')).toBeTruthy();
});
✓ an error boundary does NOT catch a throw from an event handler

ยืนยันตารางด้านบนด้วยผลลัพธ์จริง: throw ไม่ไปถึง EBoundary เลย — มันโผล่เป็น global/uncaught error แทน เหมือน JavaScript exception ธรรมดาที่อยู่นอก render ของ React

error boundary คือ circuit breaker ของ subtree ไม่ใช่ try/catch แบบ global มันตัดวงจรเฉพาะความล้มเหลวใน wiring ที่มันเป็นเจ้าของจริง ๆ — call ของ render, lifecycle, และ constructor ของ component ข้างใต้ event handler, setTimeout callback, หรือ promise ที่ reject ที่ไม่ได้อ่านผ่าน use() รันบน “วงจร” คนละเส้นเลย (event loop ของ browser เอง / async code ของคุณเอง) ดังนั้นไม่มี breaker ไหนเห็นมันเลย คุณต้องต่อ try/catch ของตัวเองตรงนั้น onCaughtError/onUncaughtError/onRecoverableError อยู่สูงกว่า breaker แต่ละตัวหนึ่งชั้น — เหมือนแผงควบคุมหลักของตึก — เพื่อให้คุณ log ได้ ทุก ครั้งที่ trip ไม่ว่า breaker ตัวไหนจะจับได้จริงหรือไม่

ได้อะไรประโยชน์ต้นทุน
error boundary รอบ widgetwidget เดียวที่พัง ไม่ทำให้ทั้งหน้าว่างเปล่าต้องเป็น class component (ไม่มี hook เทียบเท่า)
reset แบบ resetKeysboundary retry ให้อัตโนมัติเมื่อสาเหตุจริงเปลี่ยนต้องระบุให้ถูกว่า prop/ค่าไหนหมายถึง “สาเหตุเปลี่ยนแล้ว”
onCaughtError/onUncaughtErrorรายงาน error รวมศูนย์ เชื่อถือได้ สำหรับ render error ทุกตัวในแอปครอบคลุมแค่ error ช่วง render — ยังต้อง try/catch เองสำหรับ handler และ async code
onRecoverableErrorเห็น hydration mismatch ที่ React ซ่อมเงียบ ๆไม่ได้หยุด mismatch ไม่ให้เกิด — เป็นแค่ observability ไม่ใช่ fix
  • “ห่อโค้ด event handler ไว้ใน component เดียวกับ error boundary ก็พอแล้ว” ไม่พอ — boundary จับ error จาก rendering เท่านั้น ไม่ใช่จาก onClick (หรือ handler ไหนก็ตาม) พิสูจน์แล้วด้านบน handler ต้องมี try/catch ของตัวเอง
  • boundary retry อัตโนมัติเมื่อ props เปลี่ยน ไม่จริงโดย default — this.state.error ค้างอยู่จนกว่าคุณจะเคลียร์เอง resetKeys (หรือเช็ค componentDidUpdate เอง) คือสิ่งที่ทำให้ “สาเหตุเปลี่ยนแล้ว ลองใหม่” เกิดขึ้นจริง
  • output ของ console.error เป็นวิธีที่เชื่อถือได้ในการรู้ว่าแอปมี error จริง onCaughtError/onUncaughtError คือวิธีแบบ React-19-native ที่มี structure สำหรับต่อ error ทุกตัวเข้า monitoring ของคุณเอง — พร้อม componentStack ตรง ๆ ไม่ใช่แค่ log text

เวลารีวิวโค้ด error-handling ที่ generate มา ให้เช็ค:

  • component ห่อโค้ด event handler แบบ synchronous ที่เสี่ยงไว้ใน error boundary แล้วสมมติว่าพอไหม? ไม่พอ — event handler ต้องมี try/catch พิสูจน์แล้วด้านบน
  • ปุ่ม “retry” แค่ re-render tree เดิมโดยไม่เคลียร์ error state ของ boundary (หรือไม่มีเช็คแบบ resetKeys) ไหม? fallback จะไม่หายไปเลย
  • componentDidCatch/getDerivedStateFromError ถูกใช้เพื่อ กลืน ทุก error เงียบ ๆ (ไม่ report ไม่มี fallback UI) ซ่อนบั๊กจริงจากทั้ง user และ monitoring ไหม?
  • ถ้าแอปใช้ createRoot({ onCaughtError, onUncaughtError }) โค้ดที่ generate ยังคาดหวังว่า console.error เป็นสัญญาณเดียวอยู่ไหม? callback เชื่อถือได้และมี structure กว่า

🐛 หา bug ในโค้ดนี้

class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
handleRetry = () => {
this.setState({ error: null }); // no check on WHY it's being retried
};
render() {
if (this.state.error) {
return (
<div>
<p>Something went wrong.</p>
<button onClick={this.handleRetry}>Try again</button>
</div>
);
}
return this.props.children;
}
}
เฉลย

ปุ่ม “Try again” ของ boundary นี้เคลียร์ this.state.error แบบไม่มีเงื่อนไข — แต่ไม่มีอะไรเกี่ยวกับสาเหตุจริงเปลี่ยนไปเลย แค่เพราะ user คลิกปุ่ม ถ้า this.props.children ยัง throw ด้วยเหตุผลเดิมเป๊ะ (data เสีย, import พัง, อะไรก็ตามที่ crash ตอนแรก) boundary จะจับ error ตัวเดิมซ้ำทันทีและแสดง fallback อีกครั้ง ให้ภาพลวงตาว่า retry ทั้งที่ไม่ได้ retry อะไรใหม่เลย reset ที่แท้จริงต้องมีทั้ง resetKeys check (เคลียร์เฉพาะเมื่อค่าที่ผูกกับสาเหตุจริงเปลี่ยน) หรือ retry action ที่ re-fetch/re-load สิ่งที่พังไว้ก่อนเคลียร์ error

error boundary จับอะไร?
อันไหนที่ error boundary ไม่จับ?
ทำไม error boundary ยังต้องเขียนเป็น class component?
Suspense กับ error boundary ทำงานร่วมกันอย่างไร?
onCaughtError fire เมื่อไร?
const root = createRoot(el, {
  onCaughtError(error, errorInfo) {
    report(error, errorInfo.componentStack);
  },
});
handler "Try again" นี้ผิดตรงไหน?
handleRetry = () => {
  this.setState({ error: null }); // no check on WHY
};