S SmartDocs
시리즈: React.js javascript 232 줄 · 업데이트 2026-04-02

Project_ErrorBoundaryDemo.jsx

React.js/lessons/17_error_boundaries/projects/Project_ErrorBoundaryDemo.jsx

/**
 * Project: 錯誤邊界 Dashboard — Error Boundaries 練習
 *
 * 學習重點:
 * - Class Component 的 Error Boundary
 * - getDerivedStateFromError + componentDidCatch
 * - 每個 Widget 獨立包裹錯誤邊界
 * - 降級 UI 和重試機制
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { Component, useState } from 'react';

// ============================================================
// Error Boundary(Class Component — 必須用 class 才能做)
// ============================================================
class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    console.error(`[ErrorBoundary] ${this.props.name || 'Unknown'}:`, error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div style={{
          padding: '24px', borderRadius: '12px', backgroundColor: '#fef2f2',
          border: '1px solid #fecaca', textAlign: 'center',
        }}>
          <div style={{ fontSize: '2rem', marginBottom: '8px' }}>💥</div>
          <h3 style={{ color: '#991b1b', margin: '0 0 4px' }}>
            {this.props.name || '元件'}載入失敗
          </h3>
          <p style={{ color: '#b91c1c', fontSize: '0.85rem', margin: '0 0 12px' }}>
            {this.state.error?.message}
          </p>
          <button
            onClick={() => this.setState({ hasError: false, error: null })}
            style={{
              padding: '8px 20px', borderRadius: '8px', border: 'none',
              backgroundColor: '#ef4444', color: 'white', cursor: 'pointer',
              fontWeight: '600',
            }}
          >
            重試
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

// ============================================================
// Dashboard Widgets
// ============================================================
const StatsWidget = () => {
  const [crashed, setCrashed] = useState(false);
  if (crashed) throw new Error('統計元件發生錯誤!');

  return (
    <WidgetCard title="📊 統計">
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
        {[
          { label: '使用者', value: '1,234', color: '#4f46e5' },
          { label: '訂單', value: '567', color: '#22c55e' },
          { label: '收入', value: '$12,345', color: '#f59e0b' },
          { label: '成長率', value: '+15%', color: '#06b6d4' },
        ].map((stat) => (
          <div key={stat.label} style={{ textAlign: 'center', padding: '12px', borderRadius: '8px', backgroundColor: '#f8fafc' }}>
            <p style={{ margin: '0 0 4px', fontSize: '1.3rem', fontWeight: 'bold', color: stat.color }}>{stat.value}</p>
            <p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8' }}>{stat.label}</p>
          </div>
        ))}
      </div>
      <button onClick={() => setCrashed(true)} style={{
        marginTop: '12px', padding: '6px 12px', borderRadius: '6px',
        border: '1px solid #fca5a5', backgroundColor: 'white', color: '#ef4444',
        cursor: 'pointer', fontSize: '0.75rem', width: '100%',
      }}>
        模擬此元件崩潰
      </button>
    </WidgetCard>
  );
};

const ChartWidget = () => {
  const [crashed, setCrashed] = useState(false);
  if (crashed) throw new Error('圖表資料解析錯誤!');

  const data = [40, 65, 50, 80, 55, 70, 90];
  const days = ['一', '二', '三', '四', '五', '六', '日'];
  const maxVal = Math.max(...data);

  return (
    <WidgetCard title="📈 週銷售額">
      <div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '120px', padding: '0 8px' }}>
        {data.map((val, i) => (
          <div key={i} style={{ flex: 1, textAlign: 'center' }}>
            <div style={{
              height: `${(val / maxVal) * 100}px`, backgroundColor: '#818cf8',
              borderRadius: '4px 4px 0 0', transition: 'height 0.3s',
              display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '4px',
            }}>
              <span style={{ color: 'white', fontSize: '0.6rem', fontWeight: 'bold' }}>{val}</span>
            </div>
            <span style={{ fontSize: '0.7rem', color: '#94a3b8', marginTop: '4px', display: 'block' }}>{days[i]}</span>
          </div>
        ))}
      </div>
      <button onClick={() => setCrashed(true)} style={{
        marginTop: '12px', padding: '6px 12px', borderRadius: '6px',
        border: '1px solid #fca5a5', backgroundColor: 'white', color: '#ef4444',
        cursor: 'pointer', fontSize: '0.75rem', width: '100%',
      }}>
        模擬此元件崩潰
      </button>
    </WidgetCard>
  );
};

const RecentOrdersWidget = () => {
  const [crashed, setCrashed] = useState(false);
  if (crashed) throw new Error('無法載入訂單資料!');

  const orders = [
    { id: '#001', customer: 'Alice', amount: '$120', status: '已完成' },
    { id: '#002', customer: 'Bob', amount: '$85', status: '處理中' },
    { id: '#003', customer: 'Carol', amount: '$200', status: '已完成' },
  ];

  return (
    <WidgetCard title="🛒 最近訂單">
      {orders.map((o) => (
        <div key={o.id} style={{
          display: 'flex', justifyContent: 'space-between', padding: '8px 0',
          borderBottom: '1px solid #f1f5f9', fontSize: '0.85rem',
        }}>
          <span style={{ color: '#64748b' }}>{o.id}</span>
          <span style={{ color: '#0f172a' }}>{o.customer}</span>
          <span style={{ fontWeight: '600' }}>{o.amount}</span>
          <span style={{
            padding: '1px 8px', borderRadius: '4px', fontSize: '0.75rem',
            backgroundColor: o.status === '已完成' ? '#f0fdf4' : '#fffbeb',
            color: o.status === '已完成' ? '#166534' : '#92400e',
          }}>{o.status}</span>
        </div>
      ))}
      <button onClick={() => setCrashed(true)} style={{
        marginTop: '12px', padding: '6px 12px', borderRadius: '6px',
        border: '1px solid #fca5a5', backgroundColor: 'white', color: '#ef4444',
        cursor: 'pointer', fontSize: '0.75rem', width: '100%',
      }}>
        模擬此元件崩潰
      </button>
    </WidgetCard>
  );
};

const NotificationsWidget = () => {
  return (
    <WidgetCard title="🔔 通知">
      {['系統更新完成', '新使用者註冊', '訂單已出貨'].map((n, i) => (
        <div key={i} style={{ padding: '8px 0', borderBottom: '1px solid #f1f5f9', fontSize: '0.85rem', color: '#475569' }}>
          {n}
        </div>
      ))}
      <p style={{ color: '#94a3b8', fontSize: '0.75rem', margin: '8px 0 0' }}>
        此元件沒有崩潰按鈕 — 展示不受影響
      </p>
    </WidgetCard>
  );
};

const WidgetCard = ({ title, children }) => (
  <div style={{
    padding: '20px', borderRadius: '12px', backgroundColor: 'white',
    border: '1px solid #f1f5f9', height: '100%', boxSizing: 'border-box',
  }}>
    <h3 style={{ margin: '0 0 16px', fontSize: '1rem', color: '#0f172a' }}>{title}</h3>
    {children}
  </div>
);

// ============================================================
// App
// ============================================================
function App() {
  return (
    <div style={{
      maxWidth: '900px', margin: '0 auto', padding: '32px 16px',
      fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
    }}>
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>Dashboard</h1>
      <p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '8px' }}>
        每個 Widget 有獨立的 Error Boundary — 一個壞掉不會影響其他的
      </p>
      <p style={{ color: '#f59e0b', fontSize: '0.8rem', marginTop: 0, marginBottom: '24px', backgroundColor: '#fffbeb', padding: '8px 12px', borderRadius: '8px' }}>
        💡 點擊每個 Widget 下方的「模擬崩潰」按鈕來測試錯誤邊界,然後點「重試」恢復
      </p>

      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
        <ErrorBoundary name="統計">
          <StatsWidget />
        </ErrorBoundary>

        <ErrorBoundary name="圖表">
          <ChartWidget />
        </ErrorBoundary>

        <ErrorBoundary name="訂單">
          <RecentOrdersWidget />
        </ErrorBoundary>

        <ErrorBoundary name="通知">
          <NotificationsWidget />
        </ErrorBoundary>
      </div>
    </div>
  );
}

export default App;

관련 글