Error Boundaries
component เดียว crash ไม่ควรทำทั้งแอปว่างเปล่า
หัวข้อที่มีชื่อว่า “component เดียว crash ไม่ควรทำทั้งแอปว่างเปล่า”ถ้า 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["จัดการเอง"]
จับคู่กับ Suspense
หัวข้อที่มีชื่อว่า “จับคู่กับ Suspense”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 ด้วย
onCaughtError / onUncaughtError / onRecoverableError (React 19)
หัวข้อที่มีชื่อว่า “onCaughtError / onUncaughtError / onRecoverableError (React 19)”ตั้งแต่ 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 theerrorcaught by the Error Boundary, and anerrorInfoobject containing thecomponentStack.” — 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 ซ่อมเองเงียบ ๆ)
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" }); },});Reset pattern (resetKeys แบบไม่ใช้ library)
หัวข้อที่มีชื่อว่า “Reset pattern (resetKeys แบบไม่ใช้ library)”state ของ error boundary เอง (this.state.error) เคลียร์เมื่อคุณเคลียร์เท่านั้น — React ไม่ retry subtree ที่ crash ให้อัตโนมัติแค่เพราะ props เปลี่ยน resetKeys ของ library react-error-boundary คือ pattern มาตรฐานสำหรับ “เคลียร์ error เมื่อสิ่งที่เป็นสาเหตุเปลี่ยนไปจริง ๆ”; คุณทำเองด้วย componentDidUpdate ได้:
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พิสูจน์สิ่งที่ boundary ไม่จับ: event handler ที่ throw
หัวข้อที่มีชื่อว่า “พิสูจน์สิ่งที่ boundary ไม่จับ: event handler ที่ throw”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
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”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 ตัวไหนจะจับได้จริงหรือไม่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
| error boundary รอบ widget | widget เดียวที่พัง ไม่ทำให้ทั้งหน้าว่างเปล่า | ต้องเป็น class component (ไม่มี hook เทียบเท่า) |
reset แบบ resetKeys | boundary 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
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลารีวิวโค้ด 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