FE Day 37 錯誤處理與使用者回饋
執行需求:CPU 可跑。今天進入貫穿專案「預約管理系統」的錯誤處理篇。Day 36 我們把資料層從 mock 換到 FastAPI,但每一次請求都可能失敗:網路斷線、後端 500、表單驗證 422、登入逾期 401、時段衝突 409。這些失敗如果不處理,使用者看到的會是「按按鈕沒反應」、「畫面空白」或「一片紅字」。今天要做四件事:第一,把 Day 36 拋出的 ApiError 接上使用者介面;第二,寫一個 Toast 元件與 Context,集中管理短訊息;第三,用 ErrorBoundary 接住渲染階段的例外並提供重試畫面;第四,把欄位層級的驗證錯誤接回 Day 32 的 Input 元件。全程沿用 mock 模式,沒有後端也能重現所有錯誤情境。
引言
錯誤處理是前端專案最容易被低估、卻最能區分「能用」與「好用」的工作。看起來「在 catch 裡寫一行 console.log 就好」,但上線之後會出現三種症狀:使用者回報「按了沒反應」、客服收到「系統不能用」的抱怨、而監控後台每天跳出上百筆 5xx 卻沒人知道從哪裡來。這些都是「沒有結構化錯誤處理」的後果,而不是單一 bug。
今天的核心想法是把錯誤處理拆成三層,各司其職。第一層是 Toast:處理「可預期、不需要擋住畫面」的短訊息,例如操作成功、網路暫時斷線。第二層是 ErrorBoundary:處理「元件渲染時丟出例外」這種無法用 try/catch 接住的錯誤,提供整區塊的替代畫面。第三層是欄位錯誤:處理表單驗證失敗,把訊息緊貼在對應欄位下方。三層覆蓋了資料錯誤、渲染錯誤與輸入錯誤,開發者不需要在每個 catch 手寫文案。
貫穿專案的「預約管理系統」是個小型服務業線上預約平台(情境涵蓋攝影棚、健身教練、語言家教、諮詢工作室,所有資料皆為虛構示範)。Web 系列的 FastAPI 後端在 Day 36、Day 37 完成認證與預約 API,HTTP 錯誤碼遵循 REST 慣例:400 格式錯誤、401 未登入、403 權限不足、404 找不到、409 衝突、422 語意錯誤、5xx 伺服器錯誤。前端要針對每種狀態碼給出對應的回饋,而不是一律顯示紅色驚嘆號。
今天的目標有四:建立錯誤分類表、實作 Toast 與 ErrorBoundary、把欄位錯誤接回表單、並用四個具體情境驗證整套機制。讀完之後你會拿到一個完整的錯誤處理策略、約六個 React 元件與 helper、以及一份「什麼錯誤用哪種介面」的決策依據。
貫穿專案共用設定(Day 31–44 沿用)
今天的錯誤處理建立在 Day 31–36 已建好的技術堆疊上,繼續沿用:
- 框架:Next.js 15(App Router、Server Components 預設)、React 19.x、TypeScript 5.9
- 樣式:Tailwind CSS 4.x、CSS 變數主題;沿用 Day 32 的
components/ui/ - 狀態管理:TanStack Query 5.x 管理伺服器狀態
- 後端:FastAPI 預約管理系統(Web 系列 Day 35–44 定義的 REST API),錯誤格式為
{ "detail": "..." },驗證錯誤的 detail 是陣列 - Mock:
NEXT_PUBLIC_API_MODE=mock時走lib/mock/*.tsfixture - 設計系統:Day 32 的
Button、Input、Card、Badge;今天的 Toast 與 ErrorBoundary 是新元件
這份設定從 Day 31 到 Day 44 不變。今天的錯誤處理只在既有設定上新增元件與 helper,不動資料層的函式簽章,也不動路由。
原理:錯誤分類與 HTTP 狀態碼對應
錯誤處理的第一步是分類。我們把所有可能的失敗收斂成一個 UiError 物件,帶著四個欄位:顯示強度(level)、給使用者的標題與訊息、能不能重試、以及原始錯誤。任何錯誤,不論來自哪一層,最後都先經過 classifyError 轉成這個形狀,介面元件只需要看這四個欄位決定行為:
// lib/errors.ts
// 把 HTTP 狀態碼、ApiError 與一般例外統一分類成畫面可用的形狀。
import { ApiError } from "@/lib/api/client";
export type ErrorLevel = "info" | "warning" | "error" | "critical";
export type UiError = {
level: ErrorLevel;
title: string;
message: string;
retryable: boolean;
cause?: unknown;
};
const STATUS_MAP: Record<number, Omit<UiError, "cause">> = {
400: { level: "warning", title: "資料格式錯誤", message: "請檢查欄位內容後再送出", retryable: false },
401: { level: "warning", title: "登入已逾期", message: "請重新登入後再試", retryable: false },
403: { level: "error", title: "權限不足", message: "你沒有執行這個操作的權限", retryable: false },
404: { level: "info", title: "找不到資料", message: "這筆資料可能已被刪除", retryable: false },
409: { level: "warning", title: "資料衝突", message: "資料已被其他人更新,請重新整理", retryable: true },
422: { level: "warning", title: "欄位驗證失敗", message: "請檢查欄位內容", retryable: false },
};
export function classifyError(error: unknown): UiError {
if (error instanceof ApiError) {
if (error.status >= 500) {
return { level: "critical", title: "伺服器錯誤", message: "系統發生問題,請稍後再試", retryable: true, cause: error };
}
const mapped = STATUS_MAP[error.status];
if (mapped) return { ...mapped, cause: error };
}
if (error instanceof TypeError) {
return { level: "error", title: "網路錯誤", message: "請檢查網路連線後重試", retryable: true, cause: error };
}
if (error instanceof Error) {
return { level: "critical", title: "未預期的錯誤", message: error.message, retryable: false, cause: error };
}
return { level: "critical", title: "未知錯誤", message: "請聯絡客服並附上發生時間", retryable: false, cause: error };
}
這個分類表有三個設計重點。第一,5xx 一律歸為 critical 且可重試,因為伺服器問題通常是暫時的,讓使用者按一下重試就能救回大半。第二,401 歸為 warning 但不能重試,因為再試一百次也還是未登入,正確做法是引導重新登入(Day 38 會做 401 自動換 token)。第三,TypeError 在 fetch 失敗時會被瀏覽器拋出,通常代表網路斷線或跨來源被擋,因此對應「網路錯誤」而不是「系統錯誤」,訊息也該不同。
| 狀態碼 | 等級 | 可重試 | 畫面行為 |
|---|---|---|---|
| 400 / 422 | warning | 否 | 欄位錯誤緊貼欄位,其餘走 Toast |
| 401 | warning | 否 | Toast 提示並導向登入 |
| 403 / 404 | error / info | 否 | Toast 或區塊內訊息 |
| 409 | warning | 是 | Toast 加上「重新整理」按鈕 |
| 5xx | critical | 是 | Toast 或 ErrorState,提供重試 |
分類完成後,重試策略也一併定義。不是所有錯誤都該重試,而且重試之間要有等待,否則只會把後端打得更慘。我們把「可重試」的判斷與指數退避寫成一個小工具:
// lib/retry.ts
export function isRetryable(error: unknown): boolean {
if (error && typeof error === "object" && "status" in error) {
const status = Number((error as { status: unknown }).status);
return status >= 500 || status === 409 || status === 429;
}
return error instanceof TypeError;
}
export async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return await fn();
} catch (error) {
lastError = error;
if (!isRetryable(error) || attempt === attempts - 1) throw error;
// 指數退避:300ms、600ms、1200ms,避免同時重試把後端壓垮。
const delay = 2 ** attempt * 300;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
這段有兩個提醒。第一,只重試 5xx、409、429 與網路錯誤,4xx 的其他情況重試沒有意義。第二,重試次數預設三次、延遲指數成長,這是「禮貌重試」的基本原則。TanStack Query 本身也有 retry 設定,兩者擇一即可;我們把 query 層的重試設為 1,複雜的重試留給 withRetry 手動控制,避免疊加成九次。
完整實作:Toast 與 ErrorBoundary
Toast 是「非阻塞的短訊息」,適合操作成功、欄位之外的錯誤、網路斷線這類需要立即通知但不該擋住畫面的情境。我們用 Context 管理一個訊息佇列,任何元件呼叫 push 就能推送,Toast 自己處理顯示與自動消失。全部用 TypeScript 標型別,避免「餵進去的東西形狀不對」:
// lib/toast-context.tsx
"use client";
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { classifyError, type UiError } from "@/lib/errors";
export type ToastRecord = UiError & { id: string };
type ToastApi = { push: (input: unknown) => void; dismiss: (id: string) => void };
const ToastContext = createContext<ToastApi | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastRecord[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((t) => t.id !== id));
}, []);
const push = useCallback((input: unknown) => {
const ui = classifyError(input);
const id = crypto.randomUUID();
setToasts((current) => [...current, { ...ui, id }]);
const ttl = ui.level === "critical" ? 8000 : 5000;
window.setTimeout(() => dismiss(id), ttl);
}, [dismiss]);
const value = useMemo<ToastApi>(() => ({ push, dismiss }), [push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast 必須在 ToastProvider 內使用");
return ctx;
}
function ToastViewport({ toasts, onDismiss }: { toasts: ToastRecord[]; onDismiss: (id: string) => void }) {
return (
<div aria-live="polite" className="fixed right-4 top-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
role={toast.level === "critical" ? "alert" : "status"}
className="flex items-start gap-3 rounded-card border border-surface-border bg-surface p-3 shadow-sm"
>
<div className="flex-1">
<div className="text-sm font-semibold text-text-primary">{toast.title}</div>
<div className="mt-1 text-sm text-text-secondary">{toast.message}</div>
</div>
<button type="button" onClick={() => onDismiss(toast.id)} className="text-xs underline">關閉</button>
</div>
))}
</div>
);
}
這個 Toast 有四個設計重點。第一,容器用 aria-live="polite",輔助科技會在使用者操作告一段落後才唸出新訊息;critical 等級改用 role="alert" 立即朗讀。第二,自動消失時間依嚴重度調整,critical 停留 8 秒、其餘 5 秒,重要訊息不會一閃而過。第三,push 接受任何 unknown,內部統一呼叫 classifyError,呼叫端不必先判斷錯誤類型。第四,Context 值用 useMemo 穩定參考,避免子元件因為 value 每次都是新物件而重複渲染。
TanStack Query 的全域錯誤可以接到 Toast,這樣就不用在每個 query 各自寫 onError。我們把 QueryCache 與 MutationCache 的 onError 指向 push,並讓 ToastProvider 包在 QueryProvider 外層:
// app/providers.tsx
"use client";
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { DEFAULT_STALE_TIME_MS } from "@/lib/query-keys";
import { ToastProvider, useToast } from "@/lib/toast-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function QueryProvider({ children }: { children: ReactNode }) {
const toast = useToast();
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: DEFAULT_STALE_TIME_MS, refetchOnWindowFocus: false, retry: 1 },
},
queryCache: new QueryCache({ onError: (error) => toast.push(error) }),
mutationCache: new MutationCache({ onError: (error) => toast.push(error) }),
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
export function Providers({ children }: { children: ReactNode }) {
return (
<ToastProvider>
<QueryProvider>
<ErrorBoundary>{children}</ErrorBoundary>
</QueryProvider>
</ToastProvider>
);
}
這裡的順序很關鍵:ToastProvider 必須在 QueryProvider 外層,因為 QueryProvider 內要呼叫 useToast;而 ErrorBoundary 放在最內層,剛好包住所有頁面內容,Render 階段丟出的例外會被它接住。有了全域 onError,個別元件只需要處理「自己關心的錯誤」,例如表單要特別處理 422 的欄位訊息,其餘(例如背景自動重抓失敗)就自動變成 Toast。
接著是 ErrorBoundary。React 19 仍然沒有「catch 渲染錯誤」的鉤子,必須用 class component 實作 componentDidCatch 與 getDerivedStateFromError。它接住的錯誤會同時送到 Day 30 的監控層,並顯示一段可重試的替代畫面:
// components/ErrorBoundary.tsx
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "@/components/ui/Button";
import { monitor } from "@/lib/monitor";
type Props = { children: ReactNode; fallbackTitle?: string };
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
monitor.captureException(error, { extra: { componentStack: info.componentStack } });
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<main className="mx-auto max-w-2xl p-section">
<section className="rounded-card border border-danger-500 bg-danger-500/5 p-card">
<h1 className="text-xl font-semibold text-danger-600">
{this.props.fallbackTitle ?? "發生未預期的錯誤"}
</h1>
<p className="mt-3 text-text-secondary">
頁面渲染時發生問題,請先重試;若持續發生,請聯絡客服並附上發生時間。
</p>
<pre className="mt-4 overflow-auto rounded bg-surface-muted p-3 text-xs">
{this.state.error.message}
</pre>
<div className="mt-4 flex gap-2">
<Button onClick={this.reset}>重試一次</Button>
<Button variant="ghost" onClick={() => window.location.reload()}>重新整理頁面</Button>
</div>
</section>
</main>
);
}
return this.props.children;
}
}
ErrorBoundary 有四個重點。第一,getDerivedStateFromError 在 catch 階段更新 state,觸發替代畫面。第二,componentDidCatch 把錯誤與 component stack 送到監控層,這是「使用者看到畫面」與「工程師收到告警」之間的橋樑。第三,reset 把 state 清回 null 讓子樹重新渲染,適合處理「臨時性」的渲染錯誤;真正的程式錯誤則需要重新整理整頁。第四,錯誤訊息放在 pre 元素內,方便使用者複製貼上給客服,也方便工程師比對監控後台的紀錄。
元件層級的錯誤處理還有一種常見需求:某個資料區塊壞掉時,不要讓整個頁面崩潰。我們提供一個輕量的 ErrorState,讓元件在 query 失敗時顯示訊息與重試按鈕,而不是丟例外:
// components/ui/ErrorState.tsx
"use client";
import { Button } from "@/components/ui/Button";
import { classifyError } from "@/lib/errors";
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const ui = classifyError(error);
return (
<div role="alert" className="rounded-card border border-danger-500 bg-danger-500/5 p-card text-center">
<p className="font-medium text-danger-600">{ui.title}</p>
<p className="mt-1 text-sm text-text-secondary">{ui.message}</p>
{ui.retryable && onRetry ? (
<Button className="mt-3" onClick={onRetry}>重試</Button>
) : null}
</div>
);
}
欄位驗證:把錯誤接回表單
表單的錯誤是第三層。FastAPI 的 422 回應會把驗證細節放在 detail 陣列裡,每一筆帶著 loc(錯誤位置)與 msg(訊息)。我們寫一個 helper 把這個結構整理成「欄位名稱到訊息」的對照表,餵給 Day 32 Input 元件的 error prop:
// lib/form-helpers.ts
import { ApiError } from "@/lib/api/client";
export type FieldErrors = Record<string, string>;
type FastApiIssue = { loc?: unknown[]; msg?: unknown };
export function parseFieldErrors(error: unknown): FieldErrors {
if (!(error instanceof ApiError)) return {};
const payload = error.payload as { detail?: unknown } | undefined;
const detail = payload?.detail;
if (!Array.isArray(detail)) return {};
const out: FieldErrors = {};
for (const raw of detail as FastApiIssue[]) {
if (!Array.isArray(raw.loc)) continue;
const field = raw.loc[raw.loc.length - 1];
if (typeof field === "string" && !out[field]) {
out[field] = typeof raw.msg === "string" ? raw.msg : "欄位格式不正確";
}
}
return out;
}
把欄位錯誤與 Toast 串進表單送出流程,最乾淨的寫法是抽成一個專用鉤子。它負責三件事:成功時提示並讓「我的預約」清單失效重新抓取、失敗時先嘗試解析欄位錯誤、沒有欄位錯誤就交給全域 onError 處理:
// lib/api/use-create-booking.ts
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createBooking } from "@/lib/api/bookings";
import { parseFieldErrors, type FieldErrors } from "@/lib/form-helpers";
import { useToast } from "@/lib/toast-context";
import type { CreateBookingInput } from "@/lib/mock/types";
export function useCreateBooking(onFieldErrors: (errors: FieldErrors) => void) {
const toast = useToast();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateBookingInput) => createBooking(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["bookings", "mine"] });
toast.push({ level: "info", title: "預約已送出", message: "確認後會以 Email 通知", retryable: false });
},
onError: (error: unknown) => {
const fieldErrors = parseFieldErrors(error);
if (Object.keys(fieldErrors).length > 0) {
onFieldErrors(fieldErrors);
return;
}
// 其餘錯誤已由 MutationCache 的全域 onError 統一送 Toast。
},
});
}
這條「欄位錯誤進欄位、其他錯誤進 Toast」的分工是業界慣例,避免「每個欄位下面都塞一個紅色橫幅」。以 Day 34 的預約表單為例,只要把送出改成呼叫這個鉤子,並在 onFieldErrors 中把結果塞進 errors state,就能同時支援「前端 zod 驗證」與「後端 422 驗證」:前端先擋一次、後端再擋一次,兩邊都通過才真的建立預約。
錯誤訊息本身也值得遵循三個原則。第一,說清楚「發生什麼」而非只說「失敗了」:把「操作失敗」改成「這個時段已經被預約,請重新選擇」。第二,告訴使用者「下一步能做什麼」:可重試的就附上重試按鈕,需重新登入的就給登入連結。第三,不要顯示技術細節給一般使用者:stack trace 與狀態碼放進監控後台,畫面上只留可理解的訊息,需要細節時收進「顯示詳細資訊」的折疊區。
驗證:四種錯誤情境
整套機制可以用四個情境驗證,分別對應後端錯誤、驗證錯誤、網路錯誤與渲染錯誤。第一個情境:在 mock 模式的 fetchAllBookings 開頭加一行 throw new ApiError(500, "boom"),重新整理後台會看到紅色 Toast 與「重試」按鈕。第二個情境:對 createBooking 送出格式錯誤的 Email,後端(或 mock 檢查)回 422,欄位下方出現訊息。第三個情境:把開發者工具的 Network 設為 Offline 後送出,fetch 會以 TypeError 失敗,畫面跳出「網路錯誤」。第四個情境:在 BookingSummary 故意讀取一個不存在的屬性,渲染階段丟出例外,ErrorBoundary 接管並顯示重試畫面,而不是整頁白屏。
這四個情境是最完整的錯誤處理測試組合。Day 40 的測試篇章會把它們包成 Vitest 與 Playwright 的測試,讓 CI 每次合併都跑一輪;今天先用人工重現,確認每一層都真的會被觸發。
常見錯誤與踩雷
第一個踩雷是「用 try/catch 包住整個元件」。渲染階段的錯誤無法被元件的 try/catch 接住,硬包反而會把錯誤吞掉、讓畫面停在半壞的狀態。修法:try/catch 留在資料層(例如 apiFetch 內),元件只處理「呼叫 mutation」這一小段的失敗,渲染錯誤交給 ErrorBoundary。
第二個踩雷是「把欄位錯誤顯示成 Toast」。使用者把 Email 打錯,卻看到「Email 格式錯誤」出現在右上角,會不知道是哪個欄位有問題。修法:422 這類錯誤先經 parseFieldErrors 解析,送回對應欄位的 error prop;只有在找不到對應欄位時(例如 401、5xx)才走 Toast。
第三個踩雷是「Toast 停留太久或太多」。自動消失設成 30 秒會讓訊息一直擋住畫面;同時跳出十個 Toast 會讓使用者完全無法閱讀。修法:預設 5 秒(critical 8 秒)、可手動關閉,並考慮同時最多顯示三個、其餘排隊。今天的版本先求「都會出現、都會消失」,佇列等觀察到實際困擾再加。
第四個踩雷是「ErrorBoundary 放在錯的位置」。如果把 ErrorBoundary 放在 Providers 最外層、或寫在 Server Component 裡,會出現「client 元件包住 server 元件」的錯誤。修法:ErrorBoundary 是 client 元件,要放在 root layout 的 children 內部、且在所有 Provider 之後(今天的 Providers 就是這個順序)。
第五個踩雷是「錯誤訊息寫死在建構錯誤的地方」。如果每個 throw 都自己組字串,改文案時要全文搜尋。修法:把所有使用者可見的文案集中在 classifyError,元件的 throw 只帶語意(狀態碼與後端 detail),顯示文案由分類層決定,未來要國際化也只需要改一處。
效能與實務提醒
今天新增的兩個元件都很輕:Toast 約 2 KB、ErrorBoundary 約 1 KB,gzipped 後合計 3 KB 上下,對整體 bundle 影響可忽略。真正需要注意的是「Toast 的觸發頻率」:如果某個查詢每 30 秒自動重抓一次且一直失敗,會變成每 30 秒跳一個 Toast,反而讓畫面很吵。解法是對「背景自動重抓」的錯誤降低顯示等級、或只在第一次失敗時提示,後續靜默記錄到監控即可。
另一個實務提醒是「錯誤日誌要能對得起來」。使用者在畫面上看到的是「系統發生問題,請稍後再試」,但工程師需要知道是哪一個請求、哪一個版本、什麼時間。我們在 Day 30 的 monitor.captureException 會帶上 build 版本與使用者識別,兩邊對照就能快速定位。若沒有這層,客服只能拿到一句「系統壞了」,除錯會拖很久。
最後提醒「不要用 alert 做錯誤回饋」。alert 會中斷瀏覽器、無法樣式化、在手機上體驗特別差,而且會擋住使用者複製錯誤訊息。今天的 Toast、ErrorState 與欄位錯誤都是頁面內元素,既能無障礙朗讀,也不會打斷操作。
小結
今天把貫穿專案的錯誤處理從「一行 console.log」升級為三層體系:Toast 處理非阻塞通知、ErrorBoundary 處理渲染例外、欄位錯誤處理表單驗證。我們寫了 classifyError、withRetry、ToastProvider、ErrorBoundary、ErrorState、parseFieldErrors 與 useCreateBooking 共七個元件與 helper,並用四個情境驗證每一層都會被觸發。預期效益是:所有可預期錯誤都有對應畫面、所有渲染例外都能被監控抓到、欄位錯誤不再被誤認為系統錯誤。
結語
錯誤處理是專案從「能動」走向「可信」的分水嶺。今天花一整篇把三層錯誤體系建起來,明天的登入流程會直接受益:401 自動換 token 的失敗會走 Toast、登入表單的欄位驗證會走 Input 的 error prop、AuthProvider 初始化失敗會被 ErrorBoundary 接住。明天 Day 38 我們會實作登入與註冊頁、AuthProvider 與 refresh 攔截器,以及依角色導向不同首頁的邏輯。讀完這篇你應該能回答:錯誤要分幾層?哪些錯誤該重試?欄位錯誤為什麼不該用 Toast?ErrorBoundary 該放在哪裡?
延伸資源
- React 錯誤邊界官方說明:
https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary - ARIA Live Regions 無障礙通知設計:
https://developer.mozilla.org/docs/Web/Accessibility/ARIA/Attributes/aria-live - FastAPI 錯誤處理官方文件:
https://fastapi.tiangolo.com/tutorial/handling-errors/,422 驗證錯誤的結構來源。 - TanStack Query 5.x 全域錯誤處理:
https://tanstack.com/query/latest/docs/framework/react/guides/mutations#error-handling - Web 系列 Day 36 的認證 API 契約:
https://blog.hao-code.com/2025/07/web-day-36.html,401 與 refresh 的介面定義。
留言
張貼留言