這篇整理自 2025 年 11 月在前端讀書會的分享。

React 的 API 一直在迭代,Server Component 也還在發展,每隔一陣子就有新的寫法要學。與其一個一個追著 API 跑,我想用更有結構、更系統的方式,去理解這些變化背後的設計原理和心智模型,這樣下次再看到新 API,也能比較從容地面對。這是我想分享這個主題的原因。

React 這幾年的轉變

這幾年寫 React,常常會有這些疑問:

  • 寫法為什麼變得這麼快?
  • RSC、資料用的 Suspense、Actions 是什麼?
  • React 現在到底是函式庫、框架,還是執行環境?
  • Next.js 的 API 為什麼一直改?

如果把這些新功能一個一個當成新 API 來學,會覺得很零碎。它們其實是同一個架構轉變的不同面向:React 不再假設 UI 只跑在一台電腦上。

從 SPA 到 RSC

每個新寫法,都是在回應「UI 在哪裡渲染」這個問題。

  • SPA:只在瀏覽器渲染。
  • SSR:伺服器先渲染出 HTML,瀏覽器再接手。
  • Streaming:HTML 不必一次送完,準備好的部分先送。
  • RSC:伺服器和瀏覽器各自負責一部分元件,一起組成畫面。

SPA:一台電腦

SPA 的時代,UI 的邏輯全在瀏覽器裡。抓資料、渲染、管理狀態、處理更新都是瀏覽器的事,伺服器只負責提供靜態檔案。

function Page() {
	const [data, setData] = useState(null);
	useEffect(() => {
		fetch('/api/user')
			.then((res) => res.json())
			.then(setData);
	}, []);
	// ...
}

SSR:被切開的模型

SSR 讓伺服器先渲染出初始的 HTML,但 UI 的邏輯並沒有真正分到兩邊:

  • 瀏覽器拿到 HTML 後要 hydrate,常常還要再抓一次同樣的資料。
  • React 18 以前,hydration 是同步的,而且要整頁一起完成,成本很高。

伺服器只是先幫瀏覽器跑一遍,兩邊做的是同一件事。

兩台電腦的對話

Dan Abramov 的〈React for Two Computers〉提出了另一種看法:網頁本來就跑在兩台電腦上,UI 是這兩台電腦之間的對話。

伺服器渲染 RSC 樹並以串流送出,瀏覽器負責互動與狀態 伺服器處理資料、送出畫面,瀏覽器處理互動

伺服器:

  • 直接 await 資料庫或 API
  • 渲染不需要互動的部分,也就是 RSC 樹
  • 把結果用串流送到瀏覽器

瀏覽器:

  • 處理所有互動,例如 onClick、useState
  • 渲染需要互動的元件
  • 管理瀏覽器端的狀態

和 SSR 不同的地方在於,Server Component 只在伺服器上執行(也可以在 build 時執行),程式碼不會送到瀏覽器,也不需要 hydrate。兩台電腦各做自己擅長的事。

React 變成了非同步的 UI 執行環境

UI 分散在兩台電腦上,就一定是非同步的:資料要等、網路會慢、更新會失敗。React 為了處理這些事,從一個渲染函式庫,變成了一個非同步的 UI 執行環境。

它要處理的非同步,大致可以分成三種:

  • 資料什麼時候準備好:交給 Suspense。
  • 渲染可以被打斷:交給 Transition。
  • 資料的寫入流程:交給 Actions。

宣告邊界,而不是管理流程

以前處理非同步,是在元件裡自己寫判斷:

function OldDataView() {
	if (isLoading) return <Spinner />;
	if (error) return <ErrorMessage />;
	return <Data />;
}

現在的做法是告訴 React,哪裡可以等待、哪裡可能失敗:

function NewDataView() {
	return (
		<ErrorBoundary fallback={<ErrorMessage />}>
			<Suspense fallback={<Spinner />}>
				<Data />
			</Suspense>
		</ErrorBoundary>
	);
}

<Data /> 本身只描述資料到了之後的樣子。還在等的時候顯示什麼、出錯時顯示什麼,交給外層的邊界決定。React 沒有內建 ErrorBoundary 元件,要自己用 class component 寫,或用 react-error-boundary 這類套件。

這就是「邊界思維」:不自己管理非同步的流程,而是宣告它的邊界。

這其實就是 React 一開始的宣告式設計。React 剛出現時,改變的是我們更新畫面的方式:不再自己一步步操作 DOM,而是描述在某個 state 下,畫面應該長什麼樣子,怎麼更新交給 React。邊界把同樣的想法延伸到非同步:不再自己追蹤 loading、error 這些中間狀態,而是描述等待時、出錯時要顯示什麼,什麼時候切換交給 React。

核心 API 都是不同種類的邊界

API 邊界 意思
<Suspense> 資料 在這裡等資料準備好
startTransition 渲染 這次更新不急,可以晚點再畫
<form action={...}> 寫入 從送出到完成,整個更新流程交給 React
'use client' 網路:伺服器 → 瀏覽器 從這裡開始,元件會送到瀏覽器
'use server' 網路:瀏覽器 → 伺服器 這個函式可以從瀏覽器呼叫

'use client' 和 'use server' 都標在伺服器和瀏覽器的交界上,但方向相反,用途也不同。'use client' 標在元件模組上:從這個檔案開始,元件和它引入的模組都會打包到瀏覽器,伺服器可以把 props 傳給它。'use server' 標在函式上,也就是 Server Function,讓瀏覽器可以呼叫伺服器上的函式。兩者跨過邊界的資料都必須能序列化。

'use server' 不是用來標記 Server Component 的。元件不用加任何標記,預設就是 Server Component。

要注意的是,'use client' 不代表「只在瀏覽器執行」。在 Next.js 這類有 SSR 的框架裡,Client Component 第一次載入時也會先在伺服器渲染成 HTML,到了瀏覽器再 hydrate、接上互動。Server Component 則相反,只在伺服器執行,程式碼不會出現在瀏覽器裡。

用 useEffect 抓資料,變成了反模式

以前在元件掛載後,用 useEffect 抓資料:

'use client';
import { useState, useEffect } from 'react';

function Profile() {
	const [user, setUser] = useState(null);
	useEffect(() => {
		fetch('/api/user')
			.then((res) => res.json())
			.then(setUser);
	}, []);
	if (!user) return <Loading />;
	return <UserInfo user={user} />;
}

這樣要先下載 JavaScript、渲染一次、再發請求,資料才會出現。換成 Server Component,資料在伺服器上就抓好了:

// app/profile/page.jsx
import { db } from '@/lib/db';
import { UserInfo } from '@/components/user-info';

export default async function ProfilePage() {
	const user = await db.user.find();
	return <UserInfo user={user} />;
}

元件可以直接是 async 函式,不用 API 路由,也不用管載入狀態。等待的時候要顯示什麼,交給外層的 Suspense。

如果是沒有伺服器的純 SPA,在 useEffect 裡抓資料還是可行,只是容易產生請求的瀑布流,也要自己處理 race condition。React 文件建議改用框架內建的資料抓取機制,或 TanStack Query 這類有快取的函式庫。

Actions:從伺服器到瀏覽器的寫入流程

送出表單時,以前要自己管理 loading、error、success 三種狀態。Actions 把整個流程串起來:

Actions 的寫入流程:送出表單、呼叫 Server Function、更新資料、串流回新的畫面 從送出到畫面更新,中間的狀態都交給 React 和框架

  1. 使用者送出表單,pending 狀態自動變成 true,可以用 useActionState 或 useFormStatus 讀到。
  2. 表單資料序列化後送到 Server Function,這時可以用 useOptimistic 先樂觀更新畫面。
  3. 伺服器上的 Server Function 執行,例如寫入資料庫。
  4. 框架重新驗證資料,把更新後的畫面從伺服器串流回來。在 Next.js 裡是呼叫 revalidatePath 或 revalidateTag。
  5. 畫面換成最新的內容。
// actions.js
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';

// 搭配 useActionState 時,第一個參數是上一次的 state
export async function updateName(prevState, formData) {
	await db.user.update({ name: formData.get('name') });
	revalidatePath('/profile');
}
// name-form.jsx
'use client';
import { useActionState } from 'react';
import { updateName } from './actions';

export function NameForm() {
	const [state, formAction, isPending] = useActionState(updateName, null);
	return (
		<form action={formAction}>
			<input name="name" />
			<button disabled={isPending}>Save</button>
		</form>
	);
}

Next.js 怎麼實作這些邊界

Next.js 不只是「用了」React,App Router 的檔案慣例,就是把 React 的非同步語意直接變成檔案:

Next.js 檔案 對應的邊界
layout.tsx 換頁時保留的外殼
loading.tsx <Suspense>
error.tsx Error Boundary
page.tsx 預設是 Server Component,在這裡抓資料
有 'use server' 的檔案 寫入,也就是 Server Function

例如 loading.tsx 會變成包住 page.tsx 的 <Suspense> 的 fallback;error.tsx 則會變成 Error Boundary 的 fallback,而 Error Boundary 只能在瀏覽器執行,所以 error.tsx 一定要是 Client Component。要注意 error.tsx 接不到同一層 layout.tsx 的錯誤,根 layout 的錯誤要交給 global-error.tsx。

為什麼 API 一直在變

React 正處在一段語言收斂期。現在的 API 不穩定,不是一團混亂,而是在替非同步、分散式的 UI 定義一套新的宣告式語言。

幾個正在收斂的例子:

  • Suspense 從「顯示載入中」擴大成「資料準備好了沒」。
  • Actions 從處理表單送出,發展成完整的寫入流程。
  • use() 補上了瀏覽器端讀取 Promise 的方法:Server Component 可以直接 await,Client Component 不能在渲染時 await,就用 use()。伺服器建立的 Promise 也可以當成 props 傳給 Client Component,再用 use() 讀出來。

Next.js 會感覺到這些變動帶來的痛,是因為它是這套新語言的試驗場。我們學的不只是新 API,而是下一代網頁架構成形的過程。

React 的第二次大轉變

2019:元件的轉變 2024:架構的轉變
從 Class Component SPA,一台電腦
到 Function Component 加上 Hooks 分散式的執行環境,兩台電腦
影響範圍 怎麼寫一個元件 怎麼蓋一整個應用程式

RSC 早在 2020 年就公開過 demo,Next.js 13 的 App Router 也在 2022 年開始採用,但要到 2024 年底 React 19 正式發布,才成為 React 的穩定功能。

Hooks 要重新思考的是元件的生命週期,這次要重新思考的是程式碼在哪台電腦執行。

新的心智模型

把前面講的整理成七個原則,每一個都對應到具體的 API:

  1. UI 是一個非同步系統:畫面上的資料常常還沒到、可能失敗,「載入中」和「錯誤」是 UI 的常態,不是例外。
  2. React 是非同步的 UI 執行環境:等資料交給 Suspense,不急的更新交給 startTransition,寫入交給 Actions,React 負責排程這些非同步。
  3. 網頁是兩台電腦:寫元件前先想它在哪裡執行。要抓資料的放伺服器,要用 onClick、useState 的放瀏覽器。
  4. 寫法由邊界決定:不在元件裡寫 if (isLoading),而是用 <Suspense>、Error Boundary 包起來,宣告哪裡可以等、哪裡可能失敗。
  5. 資料以伺服器為優先:能在 Server Component 裡 await 的資料,就不要在瀏覽器用 useEffect 再抓一次。
  6. 寫入是一條流程:送出表單時,pending、樂觀更新、重新驗證、畫面更新是一條完整的流程,用 useActionState、useOptimistic 和 Server Function 串起來,不用自己管理每個狀態。
  7. UI 是一條串流:伺服器不必等整頁準備好,先送出外殼,每個 Suspense 邊界裡的內容準備好就接著送。

了解技術框架背後的心智模型,有助於理解 API 的變化。遇到新的 API,可以先問它劃的是哪一種邊界、解決的是哪一種非同步。看懂這一點,API 的名字和寫法之後再怎麼調整,也比較容易跟上。

參考資料