
React 19 的心智模型:Two Computers, Async Runtime, Boundary Thinking
這篇整理自 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 是這兩台電腦之間的對話。
伺服器處理資料、送出畫面,瀏覽器處理互動
伺服器:
- 直接
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 把整個流程串起來:
從送出到畫面更新,中間的狀態都交給 React 和框架
- 使用者送出表單,pending 狀態自動變成
true,可以用useActionState或useFormStatus讀到。 - 表單資料序列化後送到 Server Function,這時可以用
useOptimistic先樂觀更新畫面。 - 伺服器上的 Server Function 執行,例如寫入資料庫。
- 框架重新驗證資料,把更新後的畫面從伺服器串流回來。在 Next.js 裡是呼叫
revalidatePath或revalidateTag。 - 畫面換成最新的內容。
// 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:
- UI 是一個非同步系統:畫面上的資料常常還沒到、可能失敗,「載入中」和「錯誤」是 UI 的常態,不是例外。
- React 是非同步的 UI 執行環境:等資料交給 Suspense,不急的更新交給
startTransition,寫入交給 Actions,React 負責排程這些非同步。 - 網頁是兩台電腦:寫元件前先想它在哪裡執行。要抓資料的放伺服器,要用
onClick、useState的放瀏覽器。 - 寫法由邊界決定:不在元件裡寫
if (isLoading),而是用<Suspense>、Error Boundary 包起來,宣告哪裡可以等、哪裡可能失敗。 - 資料以伺服器為優先:能在 Server Component 裡
await的資料,就不要在瀏覽器用useEffect再抓一次。 - 寫入是一條流程:送出表單時,pending、樂觀更新、重新驗證、畫面更新是一條完整的流程,用
useActionState、useOptimistic和 Server Function 串起來,不用自己管理每個狀態。 - UI 是一條串流:伺服器不必等整頁準備好,先送出外殼,每個 Suspense 邊界裡的內容準備好就接著送。
了解技術框架背後的心智模型,有助於理解 API 的變化。遇到新的 API,可以先問它劃的是哪一種邊界、解決的是哪一種非同步。看懂這一點,API 的名字和寫法之後再怎麼調整,也比較容易跟上。
參考資料
- Dan Abramov:React for Two Computers
- Dan Abramov:What Does “use client” Do?
- Dan Abramov:React as a UI Runtime
- React v19
- React Conf 2025 Recap
- Callstack:The Complete Developer Guide to React 19, Part 1: Async Handling
- Nadia Makarevich:React Server Components: Do They Really Improve Performance?
- Huli:從 React 中學習 JavaScript 底層運作
- React 文件:Server Components
- React 文件:Server Functions
- React 文件:
<Suspense> - React 文件:useActionState
- Next.js 文件:Loading UI and Streaming
- Next.js 文件:Error Handling