• 「單向資料流」具體指什麼?它換來了哪些好處?

  • 決定「狀態放在哪一層」的原則是什麼?

  • 什麼樣的資料不該成為狀態?

  • Context 解決什麼問題,代價是什麼,何時不該用?

第 1 章講的是單一元件內部:狀態算出畫面。 真實應用有幾十層元件,這一章回答的問題是: 狀態在元件樹裡怎麼流動、應該住在哪裡。 公司程式碼裡那些「為什麼狀態放這層」「為什麼要傳 回呼」的疑惑,答案都在這裡。

單向資料流:資料往下,事件往上

React 的規則只有一條,但貫徹得非常徹底:

狀態由某一個元件擁有;資料透過 props 往下傳給子孫, 子孫想改變資料時,只能呼叫父層傳下來的回呼函式「往上報告事件」, 由擁有者決定狀態怎麼變。資料的流向永遠是單向的:向下傳值、向上傳事件。

function SearchPage({ products }: { products: Product[] }) {
  const [keyword, setKeyword] = useState('');
  const filtered = products.filter((p) => p.name.includes(keyword));

  return (
    <>
      <SearchInput value={keyword} onChange={setKeyword} />
      <ProductList items={filtered} />
    </>
  );
}

// 子元件不擁有資料,只負責顯示與回報
function SearchInput({ value, onChange }: {
  value: string;
  onChange: (next: string) => void;
}) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

為什麼要規定得這麼死?因為它買到三個對大型程式至關重要的性質:

  • 除錯有固定路徑。畫面錯了 → 找到顯示這個值的元件 → 往上找到狀態的擁有者 → 能改這個狀態的只有擁有者手上的 ,全部呼叫點一目瞭然。 對比雙向綁定盛行的年代(如 Angular 1):任何一層都可能改資料, 一個值被改動時你無法回答「是誰改的」,改一處會連鎖觸發十處。

  • 元件可以獨立理解。 的行為完全由 props 決定, 不需要知道外面的世界;同一個元件因此可以放到任何地方重用。

  • 資料只有一份。 只存在於 , 輸入框顯示的、清單過濾用的,保證是同一個值,不存在「兩邊不同步」的問題。 這就是單一事實來源(single source of truth)。

順帶解釋一個常見的命名慣例:props 介面上的回呼叫 (事件語意,「發生了什麼」), 元件內部的實作叫 (處理語意)。 子元件說「發生了 change」,父元件決定「所以狀態要怎麼變」—— 子元件回報事實,父元件決定政策。這個分工讓子元件不必知道自己被用在什麼場景。

狀態該放在哪一層

實務上最常見的資料流問題就是「狀態放錯層」。判斷原則只有兩條,而且有優先順序:

  1. 預設放在最靠近使用處的地方(colocation)。 只有一個元件用到的狀態,就放在那個元件裡。 好處:改動的影響範圍小、重新渲染的範圍小(第 3 章詳述)、 元件搬移或刪除時狀態跟著走,不留孤兒。

  2. 多個元件需要同一份狀態時,提升到它們最近的共同祖先(lifting state up)。 上面的 就是例子:輸入框和清單都依賴 , 所以它住在兩者的共同父層。

新手常犯的錯是反過來:讓兩個元件「各自持有一份」,再想辦法同步。

// 反模式:兄弟元件各自持有狀態,再用 effect 互相同步
function SearchInput({ onKeywordChange }: Props) {
  const [keyword, setKeyword] = useState('');   // 第一份
  useEffect(() => {
    onKeywordChange(keyword);                   // 用副作用把值「推」出去
  }, [keyword, onKeywordChange]);
  // ...
}
function SearchPage() {
  const [keyword, setKeyword] = useState('');   // 第二份(複本)
  // ...
}

兩份狀態代表兩個事實來源:它們會有短暫不一致的瞬間(effect 在渲染之後才跑)、 多一次不必要的渲染,而且任何一邊多了新的更新路徑,同步邏輯就得跟著長。 正確做法永遠是:把狀態提升到共同祖先,讓下面的元件變成「無狀態的顯示者」。 如果提升之後發現要穿過很多層,那是另一個問題(本章後段的 Context 與第 5 章的組合技巧處理它), 不要用「多存一份」來逃避。

為新資料決定住處時,依序問: (1) 只有一個元件用?→ 放那個元件。 (2) 幾個鄰近元件共用?→ 提升到最近共同祖先。 (3) 整個應用程式到處都要(登入者、主題、語系)?→ Context。 (4) 它其實是伺服器資料的快取?→ 交給資料抓取層(本章 2.5 節)。 (5) 它需要「重新整理後還在」或「可以分享網址」?→ 放 URL(query string),不放 React。

不要儲存算得出來的東西:衍生資料

狀態設計第二常見的錯誤:把可以從現有狀態算出來的值也存成狀態。 這是公司程式碼審查裡出現頻率最高的問題之一,值得完整看一次。

// 反模式:filtered 是 keyword + products 的推導結果,卻被存成獨立狀態
const [keyword, setKeyword] = useState('');
const [filtered, setFiltered] = useState<Product[]>([]);

useEffect(() => {
  setFiltered(products.filter((p) => p.name.includes(keyword)));
}, [products, keyword]);

這段程式碼「能動」,但有三個實質問題:

  • 多一次渲染: 改變 → 渲染一次(此時 還是舊的!) → effect 執行 → → 再渲染一次。 中間那次渲染,畫面上是「新關鍵字配舊清單」的不一致狀態。

  • 兩個事實來源: 的正確性依賴「有人記得同步它」。 未來若新增清空、重設等更新路徑,每一條都要記得維護同步,漏一條就是 bug。

  • 閱讀成本:讀者必須追完 effect 才知道 從哪來。

正確版本在第 1 章其實已經出現過——衍生資料就在渲染時直接算:

const [keyword, setKeyword] = useState('');
const filtered = products.filter((p) => p.name.includes(keyword));
// 覺得計算昂貴時才加上 useMemo(先量測再說,見第 3 章):
// const filtered = useMemo(
//   () => products.filter((p) => p.name.includes(keyword)),
//   [products, keyword]
// );

狀態是「無法從其他資料推導出來的最小事實集合」。 每次要寫 前先問:這個值能不能從現有的 props 或狀態算出來? 能,就用 在渲染時算; 不能,才配得上一個 state。 狀態越少,需要保持一致的東西越少,bug 的棲息地就越小。

順帶一提,「用 同步兩個狀態」幾乎永遠是這個反模式的變形。 你會在第 4 章看到 的正當用途清單,「改了 A 狀態之後去改 B 狀態」不在其中。

Context:跨層傳遞的通道,不是全域變數

單向資料流有個實際的痛點:狀態放得高、用得深,中間每一層都要幫忙轉傳 props—— 即使那一層自己根本不關心這個值。這叫 prop drilling。 五層十層地轉傳登入者資訊,每個中間元件的介面都被污染,重構時牽一髮動全身。

Context 就是為此存在的:讓深處的元件直接讀取遠祖先提供的值,跳過中間層。

// auth-context.tsx —— 公司程式碼裡的典型寫法
interface AuthContextValue {
  user: User | null;
  logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  // ...登入、登出邏輯...
  return (
    <AuthContext.Provider value={{ user, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// 慣例:用自訂 hook 包住 useContext,並擋掉「忘了包 Provider」的誤用
export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (ctx === null) {
    throw new Error('useAuth 必須在 <AuthProvider> 之內使用');
  }
  return ctx;
}

這段範本值得注意兩個設計: context 的預設值給 而不是假資料,配合 裡的檢查, 「忘記包 Provider」會在開發期立刻炸出明確錯誤,而不是默默用假資料渲染; 以及對外只暴露 ,使用端完全不需要知道 context 物件本身—— 這是「用 hook 封裝實作細節」的標準手法,第 4 章會正式討論。

但 Context 有兩個常被忽略的代價:

  • 更新的廣播成本:Provider 的 一變(依然是 比較), 所有讀取這個 context 的元件都會重新渲染,無論它用到的欄位有沒有變。 所以 context 適合「低頻變動」的資料:登入者、主題、語系、權限。 高頻變動的資料(輸入中的表單值、滑鼠位置)放進 context 會讓整個訂閱網一起抖動。

  • 隱式依賴:元件透過 props 拿資料時,依賴寫在介面上,一眼可見; 透過 context 拿資料時,依賴藏在實作裡,元件也因此離不開特定的 Provider 環境 (測試時得多包一層)。

穿透兩三層的 props 不是問題,是單向資料流的正常樣貌——依賴明確、可追蹤,成本只是多打幾個字。 Context 是為「幾乎整棵樹都需要、且變動不頻繁」的資料準備的例外通道。 此外,第 5 章會示範另一個常被遺忘的解法:用元件組合 (把組好的元件當 children 傳下去)讓中間層根本不需要轉傳資料。

客戶端狀態與伺服器狀態

最後一個對讀懂公司程式碼很關鍵的區分。很多專案會用 React Query、SWR 或 RTK Query 這類函式庫,新手常困惑:「這跟我自己 + 抓資料差在哪?」

差別在於資料的擁有者。「彈窗開不開」的事實來源就在前端,你設什麼就是什麼; 但「使用者列表」的事實來源在伺服器——前端存的永遠只是某個時間點的快取。 快取就帶來一整組固有問題:什麼時候算過期?兩個元件同時要這份資料,要抓兩次嗎? 使用者切換分頁回來要不要重新驗證?更新成功後相關列表怎麼失效? 這些問題與「元件狀態」無關,用 硬做, 就是在自己重新發明一個快取系統(而且通常漏掉一半的邊界情況)。

資料 特徵 建議住處
輸入中的表單值、彈窗開關 單一元件使用、生命週期短 元件內
搜尋關鍵字+結果清單 鄰近元件共用 提升到共同祖先
登入者、主題、語系 全域、低頻變動 Context
目前頁碼、篩選條件 需要可分享、可重新整理 URL query string
商品列表、使用者資料 事實來源在伺服器(快取) React Query 等資料層

讀公司程式碼時,先分清楚眼前的狀態屬於哪一類, 再判斷它的住處合不合理——很多「這程式碼好亂」的直覺, 追根究柢是伺服器狀態被當成客戶端狀態管理了。

  • 單向資料流:狀態有唯一擁有者,資料經 props 向下,事件經回呼向上;換來可追蹤、可重用、單一事實來源。

  • 狀態位置:預設 colocation,共用時提升到最近共同祖先;不要複製狀態再同步。

  • 最小狀態原則:算得出來的就不要存,衍生資料在渲染時計算。

  • Context 是低頻全域資料的跨層通道;它有廣播式重渲染與隱式依賴的代價,不是 prop drilling 的萬靈丹。

  • 伺服器資料本質是快取,交給專門的資料層管理,別用 重新發明。