• 一個「使用者管理頁」從型別到頁面的完整分層長什麼樣?

  • 每一層的每個設計決策,分別對應前面各章的哪個原則?

  • 拿到一段陌生的公司程式碼時,該按什麼順序提問?

第 1 到 7 章的觀念是零件,第 8 章用它們打造了一個元件,這一章做最後的總裝。 需求選一個幾乎每家公司都有的頁面:使用者管理—— 搜尋框(防抖)、結果表格、分頁、載入與錯誤狀態。 程式碼會完整走一遍,但重點放在每段旁邊的「為什麼」。

分層與檔案結構

features/users/
├── types.ts        // 領域型別:單一事實來源
├── api.ts          // 請求層:泛型 fetcher + 端點函式
├── useUsers.ts     // 邏輯層:狀態、防抖、抓取、競態防護
├── UserTable.tsx   // 呈現層:純顯示元件
└── UserPage.tsx    // 組裝層:接線

這個結構本身就是第 5 章「邏輯與畫面分離」的落地: 依賴方向永遠往下(頁面依賴 hook,hook 依賴 api,api 依賴 types), 每一層都能獨立閱讀與測試。公司專案的資料夾名稱可能不同,但分層幾乎一定認得出來。

types.ts:先把「什麼是合法的」說清楚

export interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'member';        // 字面量聯集:非法角色無法表達
}

export interface Paginated<T> {    // 泛型:分頁結構與內容型別解耦
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}

export type FetchState<T> =        // 標記聯集:狀態機,見第 5、6 章
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

為什麼先寫這個檔:型別是這個功能的「合約」。 讓分頁形狀全公司只定義一次; 讓「載入中卻有資料」這類非法狀態從根本上無法出現。 之後每一層都 import 這一份,後端改欄位時,紅線會自動標出所有需要跟著改的地方。

api.ts:請求邏輯寫一次

import type { Paginated, User } from './types';

async function api<T>(path: string): Promise<T> {
  const res = await fetch(`/api${path}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<T>;   // 邊界宣告:格式關鍵時改用 zod 驗證(7.8 節)
}

export function searchUsers(keyword: string, page: number) {
  const q = new URLSearchParams({ q: keyword, page: String(page) });
  return api<Paginated<User>>(`/users?${q}`);
}

為什麼:錯誤處理、base URL、JSON 解析這些「請求的固定成本」集中一處; 端點函式退化成一行,型別 從這裡出發, 流過 hook、流進元件,全程不需要再標註——泛型保留關係的實際效益。

useUsers.ts:這一頁「怎麼運作」的全部

export function useUsers() {
  const [keyword, setKeyword] = useState('');
  const [page, setPage] = useState(1);
  const debouncedKeyword = useDebounce(keyword, 300);   // 第 4 章的自訂 hook
  const [state, setState] = useState<FetchState<Paginated<User>>>({
    status: 'idle',
  });

  // 改關鍵字時回到第一頁:這是「事件的後果」,寫在事件裡,不用 effect 連鎖
  function changeKeyword(next: string) {
    setKeyword(next);
    setPage(1);          // 兩次 setState 自動批次,只渲染一次(第 3 章)
  }

  // 同步:讓「畫面上的資料」跟上「防抖後的查詢條件」
  useEffect(() => {
    let ignore = false;                    // 競態防護(第 4 章)
    setState({ status: 'loading' });
    searchUsers(debouncedKeyword, page)
      .then((data) => { if (!ignore) setState({ status: 'success', data }); })
      .catch((e)   => { if (!ignore) setState({ status: 'error', error: String(e) }); });
    return () => { ignore = true; };       // 條件又變了:宣告上一輪過期
  }, [debouncedKeyword, page]);            // 誠實申報:同步內容取決於這兩個值

  return { keyword, page, state, changeKeyword, changePage: setPage };
}

這個 hook 濃縮了最多決策,逐條對帳:

  • 狀態的選擇(第 2 章): 與 是真正的事實; 由 hook 推導;結果資料是伺服器狀態的快取, 裝在 裡。沒有一個多餘的 state。 (實務中結果快取常直接交給 React Query;這裡手寫是為了讓你看見它替你做了什麼。)

  • 「換關鍵字要回第一頁」寫在事件處理器(第 4 章): 若寫成「effect 監聽 keyword 去 setPage」,因果就斷了, 而且會多一輪「舊頁碼+新關鍵字」的錯誤請求。

  • effect 是純粹的同步(第 4 章):依賴誠實、cleanup 防競態, 快速輸入時慢回應的舊請求不會蓋掉新結果;StrictMode 雙跑也安然無恙。

呈現層:UserTable 與搜尋框

interface UserTableProps {
  users: User[];
  onSelect: (userId: string) => void;   // 對外只說「選了誰」(第 5 章)
}

// memo:表格大、且 props 在翻頁打字之外不常變 —— 值得設檢查哨(第 3 章)
export const UserTable = React.memo(function UserTable(
  { users, onSelect }: UserTableProps,
) {
  return (
    <table>
      <tbody>
        {users.map((u) => (
          <tr key={u.id} onClick={() => onSelect(u.id)}>  {/* id 當 key,不用 index */}
            <td>{u.name}</td><td>{u.email}</td><td>{u.role}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
});

UserPage.tsx:接線,然後追蹤一次資料流

export function UserPage() {
  const { keyword, page, state, changeKeyword, changePage } = useUsers();
  const navigate = useNavigate();
  const handleSelect = useCallback(
    (id: string) => navigate(`/users/${id}`),
    [navigate],
  );   // 穩定引用,讓 UserTable 的 memo 真正生效(第 3 章)

  return (
    <>
      <SearchInput value={keyword} onChange={changeKeyword} />
      {state.status === 'loading' && <Spinner />}
      {state.status === 'error' && <Alert message={state.error} />}
      {state.status === 'success' && (
        <>
          <UserTable users={state.data.items} onSelect={handleSelect} />
          <Pagination
            page={page}
            total={state.data.total}
            onChange={changePage}
          />
        </>
      )}
    </>
  );
}

注意 JSX 裡的 分支: 只在這個分支裡拿得到(標記聯集的收窄), 「忘記處理載入中就取資料」在這個設計下寫不出來。

最後,把第 3 章的渲染分析拿來走一遍「使用者打了一個字」:

時刻 發生的事
按鍵當下 :+ 批次成一次渲染; 顯示新字。 的 props(、) 引用皆未變,memo 淺比較通過,大表格不重算。
300ms 後 內的計時器到期, 更新 → effect 的依賴變了 → cleanup 將上一輪標記為過期 → 發新請求, 進入 (再一次渲染,顯示 Spinner)。
回應到達 進入 → 渲染 → 收到新的 引用,memo 比較不通過,表格以新資料重算——這次的重算正是我們要的。

能把這張表自己推出來,就代表前面各章的模型已經連成一體了。

閱讀公司程式碼的提問清單

拿到陌生程式碼時,按順序問這十二個問題,多數設計意圖會自己浮現:

看到 state 時(第 2 章):

  1. 這個狀態的擁有者是誰?為什麼放這一層——有誰在共用它?

  2. 它能不能從其他 state/props 推導出來?(能的話為什麼還存了一份?)

  3. 它是客戶端狀態,還是伺服器資料的快取?

看到 useEffect 時(第 4 章):

  1. 它在跟哪個外部系統同步?(答不出來 → 可能是誤用,找衍生資料或事件邏輯的影子)

  2. 依賴陣列誠實嗎?有沒有被刻意留空或刪減?

  3. cleanup 在防什麼?(訂閱洩漏?競態?沒有 cleanup 合理嗎?)

看到 memo/useMemo/useCallback 時(第 3 章):

  1. 下游有誰在消費這個穩定性?(memo 元件?依賴陣列?都沒有 → 可能是貨物崇拜)

  2. 被 memo 的元件,props 全部穩定了嗎?

看到型別時(第 6、7 章):

  1. 這個型別讓哪些錯誤用法寫不出來?(標記聯集?never 欄位?字面量?)

  2. 泛型參數把哪幾個位置綁定在一起?

  3. 斷言出現在哪裡?那個邊界有沒有執行期驗證?

  4. 這份型別的事實來源在哪?(手寫的??後端 schema 產生的?)

常見 code smell 對照表

日常 code review 高頻出現的模式,以及它們對應的章節與重構方向:

Smell 重構方向 章節
+ 同步一個算得出來的值 改為渲染時計算(必要時 ) 2
一叢互相牽制的布林狀態 合併成標記聯集狀態機 5, 6
effect 監聽狀態變化來執行「使用者操作的後果」 邏輯搬回事件處理器 4
依賴陣列被留空/刪減並加了 eslint-disable 誠實申報依賴,用函數式更新或 ref 解衝突 4
會增刪重排的列表用 index 當 key 改用穩定 id 3
元件函式內部定義另一個元件 搬到模組頂層,用 props 傳參 3
無差別的 / 全覆蓋 先量測,確認下游有消費者再加 3
十幾顆布林/設定 props 的「萬能元件」 variant 聯集+children/插槽組合 5
context 裝高頻變動值,整棵樹跟著抖 狀態下移,或拆 context、memo value 2, 3
直接斷言 API 回應 邊界改用 zod 驗證,內部消滅斷言 6, 7

接下來的路

這份講義刻意停在「理解既有設計」的層次。往下走的順序建議:

  1. 官方文件的兩篇必讀:react.dev 的 Thinking in React 與 You Might Not Need an Effect—— 讀完本講義再看,會有完全不同的吸收率。TypeScript 端則是官方 Handbook 的 Generics 與 Narrowing 兩章。

  2. 資料層:把手寫的 換成 React Query, 對照它文件裡的 staleTime、invalidation 概念, 你會清楚看到第 2 章「伺服器狀態」那張表的完整版。

  3. 測試:React Testing Library。 會發現「邏輯抽進 hook、介面誠實、受控元件」的設計讓測試變得順理成章—— 可測試性是好設計的免費副產品。

  4. 往框架層看:Next.js 與 React Server Components 改變的是「哪些元件在哪裡執行」,但本講義的資料流、型別、設計原則全部沿用。

最重要的練習只有一個:回到公司的程式碼庫,用 9.7 節的十二個問題掃一個你常改的模組, 把答不出來的問題拿去問資深同事——這時你們已經說同一種語言了。

  • 分層:types(合約)→ api(請求一次寫好)→ hook(運作邏輯)→ 元件(長相)→ 頁面(接線),依賴單向往下。

  • 每個決策都有出處:批次更新、事件內處理連動、effect 同步+競態防護、memo+穩定引用、標記聯集收窄。

  • 讀碼用提問清單:狀態三問、effect 三問、memo 二問、型別四問。

  • smell 對照表是 code review 的起手式;下一步是官方文件、資料層、測試。