Skip to content

Next.js Dashboard Data Flow and Rendering Strategies

Next.js Dashboard 資料流與渲染策略

Building a dashboard sounds simple — fetch data, display numbers. But the architectural decisions you make in the first hour determine whether your dashboard loads in 200ms or 2 seconds, whether it gracefully handles errors or shows a white screen, and whether your codebase stays maintainable as it grows.

This article explores a specific, common scenario: a Next.js App Router /dashboard page where 6 metric components need to display data from the same single API response, using client-side fetching. We'll cover the data fetching pattern, library choice, component architecture, Suspense boundary design, and error handling strategy.

建一個 Dashboard 聽起來很簡單——取資料、顯示數字。但你在第一個小時做出的架構決策,決定了你的 Dashboard 是 200ms 還是 2 秒載入完成、是優雅地處理錯誤還是顯示白屏、以及你的程式碼是否能在規模成長後繼續維護。

本文探討一個具體且常見的情境:一個 Next.js App Router 的 /dashboard 頁面,其中 6 個指標元件 需要顯示來自 同一支 API response 的資料,使用 client-side fetching。我們將涵蓋資料取得模式、函式庫選擇、元件架構、Suspense 邊界設計以及錯誤處理策略。


1. Problem Definition: One API, Six Metrics, How to Split?

1. 問題定義:一支 API、六個指標、怎麼拆?

The Scenario

You have a /dashboard page. A single API endpoint returns a response like:

json
{
  "revenue": 12340,
  "users": 1203,
  "orders": 89,
  "conversionRate": 3.2,
  "avgOrderValue": 138.6,
  "activeSubscriptions": 476
}

You need to display each of these 6 values in its own metric card. The page uses Next.js App Router with Server Components as the default.

Three Core Questions

  1. Data fetching: How do you fetch once and distribute to 6 components?
  2. Component architecture: How do you split Server and Client Components to maximize SSR?
  3. Loading states: How do you handle the period before data arrives?

情境

你有一個 /dashboard 頁面。一支 API 端點回傳如下 response:

json
{
  "revenue": 12340,
  "users": 1203,
  "orders": 89,
  "conversionRate": 3.2,
  "avgOrderValue": 138.6,
  "activeSubscriptions": 476
}

你需要在 6 張獨立的指標卡片中分別顯示這些值。頁面使用 Next.js App Router,預設為 Server Components。

三個核心問題

  1. 資料取得:如何只 fetch 一次並分發給 6 個元件?
  2. 元件架構:如何拆分 Server 和 Client Component 以最大化 SSR?
  3. 載入狀態:在資料到達之前如何處理?

2. Data Fetching Strategies: Four Patterns and Trade-offs

2. 資料取得策略:四種模式與取捨

Pattern A: Cache Deduplication

Each of the 6 components independently calls the same hook with the same cache key. The data fetching library (SWR or TanStack Query) automatically deduplicates into a single network request.

tsx
// Each component calls this independently
function MetricValue({ field }: { field: string }) {
  const { data } = useDashboardData()  // same cache key
  return <>{data[field]}</>
}
  • Pros: Components are self-contained, independently testable; no prop drilling; no context boilerplate; automatic background revalidation updates all consumers
  • Cons: All components must agree on the same cache key; less explicit data flow

Pattern B: Container / Presentational

One parent component fetches data, then passes it down as props to 6 presentational children.

tsx
function DashboardContainer() {
  const { data } = useDashboardData()
  return (
    <>
      <MetricA revenue={data.revenue} />
      <MetricB users={data.users} />
    </>
  )
}
  • Pros: Clearest data flow; presentational components can be pure
  • Cons: Container becomes an orchestrator; adding/removing metrics requires editing the container; single Suspense boundary is forced (all or nothing)

Pattern C: Context Provider

Fetch once inside a provider, distribute via useContext.

  • Pros: Explicit single fetch point; consumers have no dependency on the fetching library
  • Cons: Extra boilerplate; all consumers re-render when any part of data changes; forces a single Suspense boundary at the provider level

Pattern D: State Management (Zustand / Jotai)

Fetch in a useEffect or store action, put results in a global store.

  • Pros: Fine-grained subscriptions; works if you also have complex client-side state
  • Cons: Does NOT integrate with React Suspense; loses caching and revalidation benefits; mixes server-state with client-state concerns

Recommendation

PatternSuspenseSingle RequestGranular LoadingBoilerplate
A: Cache DedupYesYes (auto)YesLow
B: ContainerYes (parent only)YesNoLow
C: ContextYes (provider only)YesNoMedium
D: StoreNoYesManualHigh

Pattern A (Cache Deduplication) is the best fit. It is the idiomatic approach recommended by both SWR and TanStack Query, integrates with Suspense, and allows granular loading states per component.

Pattern A: Cache Deduplication(快取去重)

6 個元件各自獨立呼叫同一個 hook(同一個 cache key),由資料取得函式庫自動去重為單一網路請求。

tsx
// 每個元件獨立呼叫
function MetricValue({ field }: { field: string }) {
  const { data } = useDashboardData()  // 同一個 cache key
  return <>{data[field]}</>
}
  • 優點:元件自包含、可獨立測試;不需要 prop drilling;不需要 context 樣板碼;自動背景 revalidation 更新所有消費者
  • 缺點:所有元件必須使用同一個 cache key;資料流較不明確

Pattern B: Container / Presentational(容器 / 呈現)

一個父元件負責 fetch,再透過 props 傳給 6 個呈現型子元件。

tsx
function DashboardContainer() {
  const { data } = useDashboardData()
  return (
    <>
      <MetricA revenue={data.revenue} />
      <MetricB users={data.users} />
    </>
  )
}
  • 優點:資料流最清晰;呈現型元件可以是純函式
  • 缺點:容器成為調度者;新增/移除指標需要改容器;強制使用單一 Suspense 邊界(全有或全無)

Pattern C: Context Provider(Context 提供者)

在 Provider 內 fetch 一次,透過 useContext 分發。

  • 優點:明確的單一取得點;消費者不依賴特定的 fetching 函式庫
  • 缺點:額外的樣板碼;資料任何部分變動都會導致所有消費者 re-render;強制在 Provider 層級使用單一 Suspense 邊界

Pattern D: 狀態管理(Zustand / Jotai)

useEffect 或 store action 中 fetch,將結果存入全域 store。

  • 優點:細粒度訂閱;如果你同時有複雜的 client state 需求,可以整合
  • 缺點:不相容 React Suspense;失去快取和 revalidation 的好處;混淆 server-state 和 client-state 的關注點

推薦

模式Suspense單次請求粒度控制樣板碼
A: Cache Dedup是(自動)
B: Container是(父層)
C: Context是(Provider 層)
D: Store手動

Pattern A(Cache Deduplication)最適合此場景。 它是 SWR 和 TanStack Query 官方推薦的慣用做法,可與 Suspense 整合,並允許每個元件有獨立的載入狀態。


3. SWR vs TanStack Query: Key Trade-offs

3. SWR vs TanStack Query:關鍵取捨

Suspense Integration Maturity

This is the deciding factor. The gap is significant.

SWR's issues with Suspense:

  • SWR's own documentation states that using Suspense with SWR is "not recommended" because it easily causes waterfall problems
  • In { suspense: true } mode, error retry is unreliable — only one attempt is made before throwing to ErrorBoundary (GitHub issue #1907)
  • SSR requires fallbackData, but providing it eliminates the loading state
  • TypeScript: data is still Data | undefined when suspense: true is set via global SWRConfig rather than inline

TanStack Query's advantages:

  • v5 ships a dedicated useSuspenseQuery hook (the old useQuery({ suspense: true }) was removed)
  • data is typed as TData (guaranteed non-undefined) — status is narrowed to 'success' | 'error'
  • Provides QueryErrorResetBoundary for clean retry integration with ErrorBoundary
  • Errors are only thrown to ErrorBoundary when there is no cached data — if stale data exists, the component continues rendering with stale data

Request Deduplication Mechanics

SWRTanStack Query
MechanismTime window (dedupingInterval, default 2s)Structural (same queryKey = same cache entry)
RiskComponents mounting >2s apart may trigger duplicate requestsNo time dependency — same key always shares
For 6 simultaneous componentsWorks fineWorks fine, but more robust by design

Other Key Differences

AspectSWRTanStack Query
Bundle size~4.2 kB gzipped~10-12 kB gzipped
Provider requiredNoYes (QueryClientProvider)
DevToolsNo officialBuilt-in, visualize cache state
staleTime conceptNone — data always "stale"Configurable fresh duration
Garbage collectionNoneAutomatic via gcTime
MaintenanceVercel-maintained, slower releasesVery active (TkDodo), frequent releases

Verdict

For a Suspense-driven dashboard with 6 shared consumers → TanStack Query. The useSuspenseQuery hook, guaranteed TypeScript narrowing, QueryErrorResetBoundary, and structural deduplication make it the production-ready choice.

SWR remains a valid choice if you do NOT use Suspense and prefer minimal bundle size with zero-provider setup.

Suspense 整合成熟度

這是決定性因素。差距很大。

SWR 在 Suspense 方面的問題:

  • SWR 官方文件自己寫道搭配 Suspense 使用是「不推薦的」,因為容易造成 waterfall 問題
  • { suspense: true } 模式下,error retry 不可靠——只嘗試一次就拋到 ErrorBoundary(GitHub issue #1907)
  • SSR 需要 fallbackData,但提供了就會失去 loading 狀態
  • TypeScript:當 suspense: true 透過全域 SWRConfig 設定(而非 inline)時,data 仍然是 Data | undefined

TanStack Query 的優勢:

  • v5 提供專用的 useSuspenseQuery hook(舊的 useQuery({ suspense: true }) 已被移除)
  • data 型別為 TData(保證非 undefined)——status 縮窄為 'success' | 'error'
  • 提供 QueryErrorResetBoundary 搭配 ErrorBoundary 實現乾淨的 retry 機制
  • 錯誤只在沒有 cached data 時才 throw 到 ErrorBoundary——如果有 stale data,元件會繼續用 stale data render

請求去重機制

SWRTanStack Query
機制時間視窗(dedupingInterval,預設 2 秒)結構性(同 queryKey = 同 cache entry)
風險元件 mount 時間差超過 2 秒可能觸發重複請求無時間依賴——同 key 永遠共用
6 個同時 mount 的元件沒問題沒問題,且機制更穩健

其他關鍵差異

面向SWRTanStack Query
Bundle size~4.2 kB gzipped~10-12 kB gzipped
是否需要 Provider是(QueryClientProvider
DevTools無官方內建,可視化 cache 狀態
staleTime 概念無——資料永遠「stale」可設定「新鮮」時間
垃圾回收透過 gcTime 自動回收
維護力度Vercel 維護,更新較慢非常活躍(TkDodo),頻繁更新

結論

針對 Suspense 驅動的 Dashboard + 6 個共用消費者 → TanStack Query。 useSuspenseQuery hook、保證的 TypeScript narrowing、QueryErrorResetBoundary 以及結構性去重,使其成為 production-ready 的選擇。

如果你不使用 Suspense且偏好最小 bundle size + 零 Provider 設定,SWR 仍然是合理的選擇。


4. Component Architecture: Maximizing Server-Side Rendering

4. 元件架構:最大化 Server-Side Rendering

The Principle: Push "use client" as Deep as Possible

In Next.js App Router, all components are Server Components by default. Server Components ship zero JavaScript to the client — only their rendered HTML reaches the browser. The "use client" directive creates a boundary: everything below it becomes part of the client bundle.

The goal: make Client Components the leaves of the tree, not the branches.

The Thin Client Component Pattern

Instead of making the entire metric card a Client Component, split it:

Component"use client"?What it rendersJS sent to client
MetricCardNoBorder, shadow, icon, title, label0 kB
MetricValueYesJust the numberMinimal

The MetricValue Client Component might be as small as:

tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchDashboard } from '@/lib/api'

export function MetricValue({ field }: { field: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['dashboard'],
    queryFn: fetchDashboard,
  })
  return <>{data[field].toLocaleString()}</>
}

Everything else — card border, shadow, padding, icon SVG, title text, label — lives in a Server Component and costs zero client-side JavaScript.

The children Composition Pattern

A Client Component can receive Server Components as children without converting them to Client Components:

tsx
// providers.tsx — Client Component
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  return (
    <QueryClientProvider client={queryClient}>
      {children}     {/* Server Components can live here */}
    </QueryClientProvider>
  )
}

Why this works: The parent Server Component renders <Cart /> and passes the result as a serialized React node through the children prop. The Client Component never imports or executes the Server Component code — it just renders the pre-rendered output.

Critical rule: If you directly import a component inside a "use client" file, it gets silently converted into client code. The composition pattern only works when Server Components are passed via props (children, slots), not directly imported.

When Does a Component Need "use client"?

Needs "use client"Does NOT need "use client"
Uses useState, useEffectRenders static text, numbers
Uses data fetching hooksStatic layout, icons, borders
Has event handlers (onClick, onHover)CSS styling and classes
Uses browser APIs (window, Intl with locale detection)Server-side formatting
Uses interactive third-party libraries (chart libraries)Static SVGs and images

原則:將 "use client" 推到最深處

在 Next.js App Router 中,所有元件預設都是 Server Component。Server Component 送到 client 的 JavaScript 是——只有它們 render 後的 HTML 到達瀏覽器。"use client" 指令建立了一個邊界:它以下的所有東西都成為 client bundle 的一部分。

目標:讓 Client Component 成為樹的葉子,而不是枝幹。

Thin Client Component 模式

與其讓整張指標卡片成為 Client Component,不如拆分:

元件"use client"?渲染內容送到 client 的 JS
MetricCard邊框、陰影、icon、標題、標籤0 kB
MetricValue只有數字極小

MetricValue Client Component 可能只有這麼小:

tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchDashboard } from '@/lib/api'

export function MetricValue({ field }: { field: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['dashboard'],
    queryFn: fetchDashboard,
  })
  return <>{data[field].toLocaleString()}</>
}

其他所有東西——卡片邊框、陰影、padding、icon SVG、標題文字、標籤——都在 Server Component 中,不消耗任何 client-side JavaScript。

children 組合模式

Client Component 可以接收 Server Component 作為 children,而不會將它們轉為 Client Component:

tsx
// providers.tsx — Client Component
'use client'
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient())
  return (
    <QueryClientProvider client={queryClient}>
      {children}     {/* Server Component 可以在這裡 */}
    </QueryClientProvider>
  )
}

為什麼可行: 父層 Server Component 先 render <Cart />,然後將結果作為序列化的 React node 透過 children prop 傳入。Client Component 不會 import 或執行 Server Component 的程式碼——它只是 render 已經預先 render 好的輸出。

關鍵規則: 如果你在 "use client" 檔案中直接 import 一個元件,它會被靜默轉換為 client code。組合模式只有在 Server Component 透過 props(children、slots)傳入時才有效,而非直接 import。

什麼時候元件需要 "use client"

需要 "use client"不需要 "use client"
使用 useStateuseEffect渲染靜態文字、數字
使用 data fetching hook靜態 layout、icon、邊框
有 event handler(onClick、onHover)CSS styling 和 class
使用 browser API(window、帶地區偵測的 IntlServer-side 格式化
使用互動式第三方套件(chart library)靜態 SVG 和圖片

5. Suspense Boundary Design: Single vs Granular

5. Suspense 邊界設計:單一 vs 個別

Single Suspense: What Actually Happens

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <div className="grid">
    <MetricCard title="Revenue">         {/* Server Component */}
      <MetricValue field="revenue" />    {/* Client — suspends */}
    </MetricCard>
    ...5 more
  </div>
</Suspense>

When MetricValue suspends, everything inside the Suspense boundary is replaced by the fallback — including the server-rendered MetricCard shells (titles, icons, borders). They are rendered on the server, but hidden behind the fallback.

PhaseWhat the user sees
Loading<DashboardSkeleton /> (your custom skeleton)
Data arrivesAll 6 cards appear simultaneously

The server-rendered card shells are not shown early. Even though they exist in the RSC payload, the Suspense fallback covers them entirely.

Granular Suspense: Maximizing SSR

Push <Suspense> inside each card:

tsx
<div className="grid">                              {/* Always visible */}
  <MetricCard title="Revenue" icon={DollarIcon}>    {/* Always visible */}
    <Suspense fallback={<ValueSkeleton />}>
      <MetricValue field="revenue" />                {/* Suspends */}
    </Suspense>
  </MetricCard>

  <MetricCard title="Users" icon={UsersIcon}>       {/* Always visible */}
    <Suspense fallback={<ValueSkeleton />}>
      <MetricValue field="users" />                  {/* Suspends */}
    </Suspense>
  </MetricCard>
  ...4 more
</div>
PhaseWhat the user sees
LoadingReal card shells (titles, icons, borders) + small skeletons where numbers will be
Data arrivesSkeletons replaced by real numbers

Visual Comparison

Single Suspense:
┌──────────────────────────────────┐
│  ░░░░░░░░░  ░░░░░░░░░  ░░░░░░░ │  ← entire skeleton
│  ░░░░░░░░░  ░░░░░░░░░  ░░░░░░░ │
└──────────────────────────────────┘
        ↓ data arrives ↓
┌──────────────────────────────────┐
│  $12,340     1,203      89      │  ← everything appears at once
│  Revenue     Users      Orders  │
└──────────────────────────────────┘


Granular Suspense:
┌──────────────────────────────────┐
│  $ Revenue   # Users    @ Orders│  ← real card shells (SSR)
│  ░░░░░       ░░░░░      ░░░░░  │  ← only numbers are skeleton
└──────────────────────────────────┘
        ↓ data arrives ↓
┌──────────────────────────────────┐
│  $ Revenue   # Users    @ Orders│  ← unchanged, already there
│  $12,340     1,203      89      │  ← numbers fill in
└──────────────────────────────────┘

Decision Matrix

FactorSingle SuspenseGranular (6) Suspense
SSR utilizationLow — card shells hidden by fallbackHigh — card shells visible immediately
FCP (First Contentful Paint)Shows skeletonShows real UI structure
CLS (Cumulative Layout Shift)Skeleton must precisely match final layoutNear zero — shells don't move, only numbers fill in
Skeleton maintenanceMust maintain a full dashboard skeletonOnly need one small ValueSkeleton
Error isolationOne error affects everythingCan isolate per card
Visual resultAll appear at onceAll appear at once (same API)
ComplexityLowerSlightly higher

Why Granular Suspense Still Shows All Cards Simultaneously

Since all 6 MetricValue components share the same queryKey, TanStack Query deduplicates them into a single network request. When that one request resolves, all 6 Suspense boundaries resolve at the same time. You get the benefits of granular boundaries (SSR utilization, error isolation, minimal CLS) without the "popcorn effect" of cards appearing one by one.

Implementation: Suspense Inside the Server Component

<Suspense> is a React primitive — it does NOT require "use client". You can embed it directly in your Server Component:

tsx
// components/MetricCard.tsx — Server Component (no "use client")
import { Suspense } from 'react'
import { ValueSkeleton } from './ValueSkeleton'

export function MetricCard({
  title,
  icon,
  children,
}: {
  title: string
  icon: React.ReactNode
  children: React.ReactNode
}) {
  return (
    <div className="rounded-lg border p-4 shadow">
      <div className="flex items-center gap-2 text-sm text-muted">
        {icon}
        <span>{title}</span>
      </div>
      <div className="mt-2 text-2xl font-bold">
        <Suspense fallback={<ValueSkeleton />}>
          {children}
        </Suspense>
      </div>
    </div>
  )
}

The Suspense boundary lives inside the Server Component. The children (a Client Component) is the only part that can suspend.

單一 Suspense:實際發生什麼

tsx
<Suspense fallback={<DashboardSkeleton />}>
  <div className="grid">
    <MetricCard title="Revenue">         {/* Server Component */}
      <MetricValue field="revenue" />    {/* Client — suspends */}
    </MetricCard>
    ...5 more
  </div>
</Suspense>

MetricValue suspend 時,Suspense 邊界內的所有東西都被替換成 fallback——包括那些已經 server-rendered 的 MetricCard 殼(標題、icon、邊框)。它們雖然在 server 上已經 render 好了,但被 fallback 整個蓋住。

階段使用者看到什麼
載入中<DashboardSkeleton />(你自訂的骨架畫面)
資料到了6 張卡片同時出現

Server-rendered 的卡片殼不會提早顯示。即使它們存在於 RSC payload 中,Suspense fallback 會完全蓋住它們。

個別 Suspense:最大化 SSR

<Suspense> 推到每張卡片內部:

tsx
<div className="grid">                              {/* 永遠可見 */}
  <MetricCard title="Revenue" icon={DollarIcon}>    {/* 永遠可見 */}
    <Suspense fallback={<ValueSkeleton />}>
      <MetricValue field="revenue" />                {/* Suspends */}
    </Suspense>
  </MetricCard>

  <MetricCard title="Users" icon={UsersIcon}>       {/* 永遠可見 */}
    <Suspense fallback={<ValueSkeleton />}>
      <MetricValue field="users" />                  {/* Suspends */}
    </Suspense>
  </MetricCard>
  ...4 more
</div>
階段使用者看到什麼
載入中真實的卡片殼(標題、icon、邊框)+ 數字位置是小骨架
資料到了骨架被替換成真正的數字

視覺對比

單一 Suspense:
┌──────────────────────────────────┐
│  ░░░░░░░░░  ░░░░░░░░░  ░░░░░░░ │  ← 全部是 skeleton
│  ░░░░░░░░░  ░░░░░░░░░  ░░░░░░░ │
└──────────────────────────────────┘
        ↓ 資料到了 ↓
┌──────────────────────────────────┐
│  $12,340     1,203      89      │  ← 全部同時出現
│  Revenue     Users      Orders  │
└──────────────────────────────────┘


個別 Suspense:
┌──────────────────────────────────┐
│  $ Revenue   # Users    @ Orders│  ← 真實的 card shell(SSR)
│  ░░░░░       ░░░░░      ░░░░░  │  ← 只有數字是 skeleton
└──────────────────────────────────┘
        ↓ 資料到了 ↓
┌──────────────────────────────────┐
│  $ Revenue   # Users    @ Orders│  ← 不動,本來就在
│  $12,340     1,203      89      │  ← 數字填入
└──────────────────────────────────┘

決策矩陣

因素單一 Suspense個別(6 個)Suspense
SSR 利用率低——卡片殼被 fallback 蓋住高——卡片殼立即可見
FCP顯示 skeleton顯示真實 UI 結構
CLSskeleton 與最終 layout 需精確匹配接近零——殼不動,只有數字填入
Skeleton 維護成本要維護完整的 dashboard skeleton只需維護一個小的 ValueSkeleton
錯誤隔離一個 error 影響全部可以逐一隔離
視覺效果全部同時出現全部同時出現(同一 API)
複雜度較低略高

為什麼個別 Suspense 仍然會讓所有卡片同時出現

因為 6 個 MetricValue 共用同一個 queryKey,TanStack Query 將它們去重為單一網路請求。當這一個請求 resolve 時,所有 6 個 Suspense 邊界會同時 resolve。你得到個別邊界的所有好處(SSR 利用率、錯誤隔離、最小 CLS),卻沒有卡片一張一張跳出來的「爆米花效應」。

實作:Suspense 放在 Server Component 內

<Suspense> 是 React primitive——它不需要 "use client"。你可以直接嵌在 Server Component 中:

tsx
// components/MetricCard.tsx — Server Component(沒有 "use client")
import { Suspense } from 'react'
import { ValueSkeleton } from './ValueSkeleton'

export function MetricCard({
  title,
  icon,
  children,
}: {
  title: string
  icon: React.ReactNode
  children: React.ReactNode
}) {
  return (
    <div className="rounded-lg border p-4 shadow">
      <div className="flex items-center gap-2 text-sm text-muted">
        {icon}
        <span>{title}</span>
      </div>
      <div className="mt-2 text-2xl font-bold">
        <Suspense fallback={<ValueSkeleton />}>
          {children}
        </Suspense>
      </div>
    </div>
  )
}

Suspense 邊界在 Server Component 內部。children(Client Component)是唯一可能 suspend 的部分。


6. ErrorBoundary: Why It Is Essential in Suspense Mode

6. ErrorBoundary:為什麼在 Suspense 模式下不可或缺

Suspense Uses Throw-Based Control Flow

This is not a design preference — it is a consequence of how React's Fiber reconciler works.

StateComponent behaviorWho catches it?
PendingThrows a Promise<Suspense> → shows fallback
ResolvedReturns JSX normallyNo catch needed
RejectedThrows an Error<ErrorBoundary> → shows error UI

Suspense only handles Promises (pending state). When an API call fails and the Promise rejects, the resulting Error passes through the Suspense boundary and bubbles up looking for an ErrorBoundary.

What Happens Without ErrorBoundary

The entire React tree unmounts → white screen.

  1. useSuspenseQuery's fetch fails → Promise rejects
  2. Error is thrown into the React Fiber tree
  3. Suspense boundary ignores it (it only handles thenables)
  4. Error bubbles up, finds no ErrorBoundary
  5. React unmounts the entire component tree
  6. User sees a completely blank page
  7. Console shows an uncaught error

React team's design rationale: "It is worse to leave corrupted UI in place than to completely remove it."

Next.js error.tsx: Built-in Protection

In Next.js App Router, error.tsx files automatically generate ErrorBoundaries at the route segment level:

app/
  dashboard/
    page.tsx       ← your page
    error.tsx      ← auto-generates ErrorBoundary
    loading.tsx    ← auto-generates Suspense

Next.js creates the correct nesting for you:

<Layout>
  <ErrorBoundary fallback={<error.tsx />}>     ← from error.tsx
    <Suspense fallback={<loading.tsx />}>       ← from loading.tsx
      <Page />                                  ← from page.tsx
    </Suspense>
  </ErrorBoundary>
</Layout>

This provides page-level error protection. But if you use granular Suspense boundaries (6 per card), a single error still replaces the entire page because the ErrorBoundary is at the page level, not the card level.

Correct Nesting Order

ErrorBoundary (outer) → Suspense (middle) → Component (inner)

Reversing this (Suspense outer, ErrorBoundary inner) causes problems:

  • Users don't see the loading → error transition
  • If the Suspense fallback itself throws, nothing catches it

TanStack Query's Automatic Retry

Before an error reaches the ErrorBoundary, TanStack Query retries the failed request by default 3 times with exponential backoff. Most transient network issues resolve during the retry phase. The user sees the loading state (Suspense fallback) throughout the retries — the error fallback only appears after all retries are exhausted.

Practical Implementation

For a dashboard where you don't need per-card error handling, error.tsx is sufficient:

tsx
// app/dashboard/error.tsx
'use client'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex flex-col items-center justify-center gap-4 p-8">
      <h2>Failed to load dashboard</h2>
      <p className="text-muted">{error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  )
}

Combined with TanStack Query's 3 automatic retries, this provides robust error handling: retry silently 3 times, then show a user-facing error page with a manual retry button.

Suspense 使用 Throw-Based 控制流

這不是設計偏好——而是 React Fiber reconciler 運作方式的結果。

狀態元件行為誰接住?
PendingThrow 一個 Promise<Suspense> → 顯示 fallback
Resolved正常 return JSX不需要接住
RejectedThrow 一個 Error<ErrorBoundary> → 顯示 error UI

Suspense 只處理 Promise(pending 狀態)。當 API 呼叫失敗、Promise reject 時,產生的 Error 會穿透 Suspense 邊界繼續往上冒泡,尋找 ErrorBoundary。

沒有 ErrorBoundary 會發生什麼

整個 React tree 被 unmount → 白屏。

  1. useSuspenseQuery 的 fetch 失敗 → Promise reject
  2. Error 被 throw 進 React Fiber tree
  3. Suspense boundary 不理會它(它只處理 thenable)
  4. Error 繼續往上冒泡,找不到 ErrorBoundary
  5. React unmount 整個 component tree
  6. 使用者看到完全空白的頁面
  7. Console 出現 uncaught error

React 團隊的設計理念:「顯示損壞的 UI 比顯示空白更糟。」

Next.js error.tsx:內建保護

在 Next.js App Router 中,error.tsx 檔案會在 route segment 層級自動產生 ErrorBoundary

app/
  dashboard/
    page.tsx       ← 你的頁面
    error.tsx      ← 自動產生 ErrorBoundary
    loading.tsx    ← 自動產生 Suspense

Next.js 自動幫你建立正確的巢狀結構:

<Layout>
  <ErrorBoundary fallback={<error.tsx />}>     ← 來自 error.tsx
    <Suspense fallback={<loading.tsx />}>       ← 來自 loading.tsx
      <Page />                                  ← 來自 page.tsx
    </Suspense>
  </ErrorBoundary>
</Layout>

這提供了 page-level 的錯誤保護。但如果你使用個別 Suspense 邊界(每張卡片一個),一個 error 仍然會替換整個頁面,因為 ErrorBoundary 是在 page 層級而非卡片層級。

正確的巢狀順序

ErrorBoundary(外層)→ Suspense(中層)→ Component(內層)

反過來放(Suspense 在外,ErrorBoundary 在內)會導致問題:

  • 使用者看不到 loading → error 的過渡
  • 如果 Suspense fallback 本身出錯,沒有東西接住

TanStack Query 的自動重試

在 error 到達 ErrorBoundary 之前,TanStack Query 預設會以指數退避重試 3 次。大多數暫時性網路問題在重試階段就會解決。使用者在重試期間看到的是 loading 狀態(Suspense fallback)——error fallback 只會在所有重試耗盡後才出現。

實務實作

對於不需要逐一卡片處理錯誤的 Dashboard,error.tsx 就足夠了:

tsx
// app/dashboard/error.tsx
'use client'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex flex-col items-center justify-center gap-4 p-8">
      <h2>Dashboard 載入失敗</h2>
      <p className="text-muted">{error.message}</p>
      <button onClick={reset}>重試</button>
    </div>
  )
}

搭配 TanStack Query 的 3 次自動重試,提供穩健的錯誤處理:靜默重試 3 次,然後顯示一個有手動重試按鈕的使用者錯誤頁面。


7. Complete Architecture Example

7. 完整架構範例

Component Tree

app/dashboard/page.tsx            → Server Component (static shell)
app/dashboard/error.tsx           → Client Component (auto ErrorBoundary)
app/dashboard/providers.tsx       → Client Component (QueryClientProvider)
components/MetricCard.tsx         → Server Component (card shell + Suspense)
components/MetricValue.tsx        → Client Component (hook + number display)
components/ValueSkeleton.tsx      → Server Component (skeleton placeholder)
lib/api.ts                        → Shared fetcher function

app/dashboard/providers.tsx

tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 2,  // 2 minutes
            gcTime: 1000 * 60 * 10,    // 10 minutes
            retry: 3,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

app/dashboard/page.tsx

tsx
import { Providers } from './providers'
import { MetricCard } from '@/components/MetricCard'
import { MetricValue } from '@/components/MetricValue'
import {
  DollarSign, Users, ShoppingCart,
  TrendingUp, CreditCard, Activity,
} from 'lucide-react'

export default function DashboardPage() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-6">Dashboard</h1>
      <Providers>
        <div className="grid grid-cols-3 gap-4">
          <MetricCard title="Revenue" icon={<DollarSign />}>
            <MetricValue field="revenue" />
          </MetricCard>
          <MetricCard title="Users" icon={<Users />}>
            <MetricValue field="users" />
          </MetricCard>
          <MetricCard title="Orders" icon={<ShoppingCart />}>
            <MetricValue field="orders" />
          </MetricCard>
          <MetricCard title="Conversion" icon={<TrendingUp />}>
            <MetricValue field="conversionRate" suffix="%" />
          </MetricCard>
          <MetricCard title="Avg Order" icon={<CreditCard />}>
            <MetricValue field="avgOrderValue" prefix="$" />
          </MetricCard>
          <MetricCard title="Subscriptions" icon={<Activity />}>
            <MetricValue field="activeSubscriptions" />
          </MetricCard>
        </div>
      </Providers>
    </main>
  )
}

components/MetricCard.tsx

tsx
import { Suspense } from 'react'
import { ValueSkeleton } from './ValueSkeleton'

export function MetricCard({
  title,
  icon,
  children,
}: {
  title: string
  icon: React.ReactNode
  children: React.ReactNode
}) {
  return (
    <div className="rounded-lg border bg-card p-4 shadow-sm">
      <div className="flex items-center gap-2 text-sm text-muted-foreground">
        {icon}
        <span>{title}</span>
      </div>
      <div className="mt-2 text-2xl font-bold">
        <Suspense fallback={<ValueSkeleton />}>
          {children}
        </Suspense>
      </div>
    </div>
  )
}

components/MetricValue.tsx

tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchDashboard } from '@/lib/api'

export function MetricValue({
  field,
  prefix = '',
  suffix = '',
}: {
  field: string
  prefix?: string
  suffix?: string
}) {
  const { data } = useSuspenseQuery({
    queryKey: ['dashboard'],
    queryFn: fetchDashboard,
  })

  const value = data[field]
  const formatted = typeof value === 'number'
    ? value.toLocaleString()
    : value

  return <>{prefix}{formatted}{suffix}</>
}

What Gets Sent to the Client

ElementHTML (SSR)JavaScript
<h1>Dashboard</h1>Yes0 kB
Grid layout (div.grid)Yes0 kB
Card shells (border, title, icon)Yes0 kB
<ValueSkeleton /> (initial)Yes0 kB
<Providers>-QueryClient setup
<MetricValue /> (×6)-Hook + render (~20 lines each)
TanStack Query-~10-12 kB

元件樹

app/dashboard/page.tsx            → Server Component(靜態外殼)
app/dashboard/error.tsx           → Client Component(自動 ErrorBoundary)
app/dashboard/providers.tsx       → Client Component(QueryClientProvider)
components/MetricCard.tsx         → Server Component(卡片殼 + Suspense)
components/MetricValue.tsx        → Client Component(hook + 數字顯示)
components/ValueSkeleton.tsx      → Server Component(骨架佔位符)
lib/api.ts                        → 共用 fetcher 函式

app/dashboard/providers.tsx

tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 2,  // 2 分鐘
            gcTime: 1000 * 60 * 10,    // 10 分鐘
            retry: 3,
          },
        },
      }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

app/dashboard/page.tsx

tsx
import { Providers } from './providers'
import { MetricCard } from '@/components/MetricCard'
import { MetricValue } from '@/components/MetricValue'
import {
  DollarSign, Users, ShoppingCart,
  TrendingUp, CreditCard, Activity,
} from 'lucide-react'

export default function DashboardPage() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-6">Dashboard</h1>
      <Providers>
        <div className="grid grid-cols-3 gap-4">
          <MetricCard title="Revenue" icon={<DollarSign />}>
            <MetricValue field="revenue" />
          </MetricCard>
          <MetricCard title="Users" icon={<Users />}>
            <MetricValue field="users" />
          </MetricCard>
          <MetricCard title="Orders" icon={<ShoppingCart />}>
            <MetricValue field="orders" />
          </MetricCard>
          <MetricCard title="Conversion" icon={<TrendingUp />}>
            <MetricValue field="conversionRate" suffix="%" />
          </MetricCard>
          <MetricCard title="Avg Order" icon={<CreditCard />}>
            <MetricValue field="avgOrderValue" prefix="$" />
          </MetricCard>
          <MetricCard title="Subscriptions" icon={<Activity />}>
            <MetricValue field="activeSubscriptions" />
          </MetricCard>
        </div>
      </Providers>
    </main>
  )
}

components/MetricCard.tsx

tsx
import { Suspense } from 'react'
import { ValueSkeleton } from './ValueSkeleton'

export function MetricCard({
  title,
  icon,
  children,
}: {
  title: string
  icon: React.ReactNode
  children: React.ReactNode
}) {
  return (
    <div className="rounded-lg border bg-card p-4 shadow-sm">
      <div className="flex items-center gap-2 text-sm text-muted-foreground">
        {icon}
        <span>{title}</span>
      </div>
      <div className="mt-2 text-2xl font-bold">
        <Suspense fallback={<ValueSkeleton />}>
          {children}
        </Suspense>
      </div>
    </div>
  )
}

components/MetricValue.tsx

tsx
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { fetchDashboard } from '@/lib/api'

export function MetricValue({
  field,
  prefix = '',
  suffix = '',
}: {
  field: string
  prefix?: string
  suffix?: string
}) {
  const { data } = useSuspenseQuery({
    queryKey: ['dashboard'],
    queryFn: fetchDashboard,
  })

  const value = data[field]
  const formatted = typeof value === 'number'
    ? value.toLocaleString()
    : value

  return <>{prefix}{formatted}{suffix}</>
}

什麼會送到 Client

元素HTML(SSR)JavaScript
<h1>Dashboard</h1>0 kB
Grid layout(div.grid0 kB
卡片殼(邊框、標題、icon)0 kB
<ValueSkeleton />(初始)0 kB
<Providers>-QueryClient 設定
<MetricValue />(×6)-Hook + render(每個約 20 行)
TanStack Query-~10-12 kB

8. Summary: Decision Quick Reference

8. 總結:決策速查表

DecisionRecommendationWhy
Data fetching patternCache DeduplicationSelf-contained components; automatic single request; Suspense compatible
LibraryTanStack QueryuseSuspenseQuery with guaranteed types; QueryErrorResetBoundary; structural dedup; DevTools
Component splitServer shell + thin Client islandMaximizes SSR; minimizes client JS; card shells visible immediately
Suspense boundariesGranular (inside each card)Real card shells visible during loading; near-zero CLS; lower skeleton maintenance
Error handlingerror.tsx + TanStack Query's 3 auto-retriesPage-level ErrorBoundary for free; retries handle transient failures silently
Nesting orderErrorBoundary → Suspense → ComponentSuspense catches promises; ErrorBoundary catches errors

The architecture minimizes what ships to the client while maximizing what the user sees immediately. Server Components render the dashboard structure as static HTML. Six tiny Client Components — each just a hook call and a number — are the only JavaScript islands. TanStack Query deduplicates them into a single network request, and Suspense boundaries inside each card ensure the server-rendered shells are never hidden behind a loading skeleton.

決策推薦原因
資料取得模式Cache Deduplication元件自包含;自動單次請求;Suspense 相容
函式庫TanStack QueryuseSuspenseQuery 保證型別;QueryErrorResetBoundary;結構性去重;DevTools
元件拆分Server 外殼 + 極薄 Client island最大化 SSR;最小化 client JS;卡片殼立即可見
Suspense 邊界個別(在每張卡片內部)載入時可見真實卡片殼;接近零 CLS;更低的 skeleton 維護成本
錯誤處理error.tsx + TanStack Query 的 3 次自動重試免費的 page-level ErrorBoundary;重試靜默處理暫時性故障
巢狀順序ErrorBoundary → Suspense → ComponentSuspense 接住 Promise;ErrorBoundary 接住 Error

這個架構最小化了送到 client 的東西,同時最大化了使用者立即看到的內容。Server Component 將 Dashboard 結構 render 為靜態 HTML。6 個極薄的 Client Component——每個只有一個 hook 呼叫和一個數字——是唯一的 JavaScript island。TanStack Query 將它們去重為單一網路請求,而每張卡片內部的 Suspense 邊界確保 server-rendered 的殼永遠不會被 loading skeleton 遮住。