cover

為什麼需要談架構

小型 React 專案隨意寫都能動。但當元件超過 50 個、狀態邏輯交叉耦合、多人協作開始出現衝突,「架構」就不再是可選項。

本文不是 React 入門教學。如果你需要了解 React 的基本概念,請先看 React Hooks。本文聚焦在:你已經會寫 React 了,如何寫出可維護的 React 應用


元件設計模式

元件分層

flowchart TD
    subgraph Pages["頁面層 Pages"]
        P1["UserListPage"]
        P2["UserDetailPage"]
    end

    subgraph Features["功能層 Features"]
        F1["UserTable"]
        F2["UserProfile"]
        F3["UserForm"]
    end

    subgraph UI["UI 層"]
        U1["Button"]
        U2["Modal"]
        U3["Table"]
        U4["Avatar"]
    end

    P1 --> F1
    P2 --> F2
    P2 --> F3
    F1 --> U3
    F2 --> U4
    F3 --> U1
    F3 --> U2
層級職責特徵
Pages路由對應、資料載入、Layout 組合知道 URL、知道 API
Features業務邏輯、功能區塊知道 domain、不知道 URL
UI純 UI 元件、無業務邏輯只知道 props、可跨專案複用

判斷元件該放哪一層的問題

  • 這個元件換到另一個專案還能用嗎?→ UI 層
  • 這個元件換到另一個頁面還能用嗎?→ Features 層
  • 都不行 → Pages 層

Container / Presentational 分離

雖然 Hooks 讓這個模式不再「必要」,但分離邏輯和呈現的思路仍然重要:

// Container:負責邏輯
function UserListContainer() {
  const { users, loading, error } = useUsers();
  const handleDelete = useDeleteUser();
 
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <UserList users={users} onDelete={handleDelete} />;
}
 
// Presentational:負責呈現
function UserList({ users, onDelete }) {
  return (
    <ul>
      {users.map(user => (
        <UserCard key={user.id} user={user} onDelete={onDelete} />
      ))}
    </ul>
  );
}

為什麼要分離

  • Presentational 元件可以用 Storybook 獨立開發和測試
  • 邏輯變更不影響 UI,UI 變更不影響邏輯
  • 同一份資料可以有不同的呈現方式(list / grid / table)

Compound Components

當一組元件需要共享隱含的狀態時,使用 Compound Components:

// 使用方式——直覺的宣告式 API
<Tabs defaultValue="profile">
  <Tabs.List>
    <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="profile">Profile content</Tabs.Content>
  <Tabs.Content value="settings">Settings content</Tabs.Content>
</Tabs>

Compound Components 的特徵:

  • 父元件透過 Context 共享狀態給子元件
  • 子元件可以任意組合順序
  • API 對使用者來說是宣告式的

適用場景:Tabs、Accordion、Dropdown、Form 等需要元件間隱含狀態同步的 UI。


狀態管理策略

狀態分類

flowchart TD
    State["狀態"] --> Local["Local State\n元件內部狀態"]
    State --> Shared["Shared State\n跨元件共享"]
    State --> Server["Server State\n伺服器資料快取"]
    State --> URL["URL State\n路由參數、查詢字串"]
狀態類型管理方式範例
LocaluseState / useReducer表單輸入、Modal 開關
SharedContext / Zustand / Jotai使用者登入狀態、主題設定
ServerReact Query / SWRAPI 資料、分頁、快取
URLReact Router搜尋條件、分頁頁碼、篩選器

選擇原則:先問「這個狀態住在哪裡最自然」

flowchart TD
    Q1{"只有一個元件用?"} -->|是| Local["useState"]
    Q1 -->|否| Q2{"來自伺服器?"}
    Q2 -->|是| Server["React Query / SWR"]
    Q2 -->|否| Q3{"需要出現在 URL?"}
    Q3 -->|是| URL["URL Search Params"]
    Q3 -->|否| Q4{"跨多少元件?"}
    Q4 -->|"2-3 個相鄰元件"| Lift["提升到共同父元件"]
    Q4 -->|"多個不相鄰元件"| Global["Zustand / Jotai / Context"]

Server State 不是全域狀態

這是很多人犯的錯誤:把 API 回傳的資料放進 Redux / Context,然後手動管理 loading、error、refetch、cache invalidation。

用 React Query / SWR 就對了:

function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => api.getUsers(),
  });
 
  // 不需要:
  // - 手動設定 loading state
  // - 手動 dispatch action
  // - 手動處理 cache
  // - 手動處理 refetch
}

React Query 自動處理:

  • Loading / Error / Success 狀態
  • 快取和自動重新驗證
  • 背景更新(stale-while-revalidate)
  • 重試和請求去重

全域狀態:越少越好

2016: 所有狀態都放 Redux
2020: 伺服器狀態用 React Query,剩下的放 Context
2024: 大多數「全域狀態」其實是 Server State 或 URL State
2026: 真正需要全域管理的狀態其實很少

真正需要全域狀態管理的場景:

  • 使用者認證狀態
  • 主題 / 語言偏好
  • 通知佇列
  • 多步驟表單的跨頁資料

Custom Hooks 設計

Custom Hooks 是 React 架構的核心武器——把邏輯從元件中抽離出來。

好的 Custom Hook

// 職責單一、命名清晰、回傳值有意義
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
 
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
 
  return debounced;
}

Custom Hook 設計原則

原則說明反例
單一職責一個 Hook 做一件事useEverything()
回傳明確回傳值語義清晰回傳 10 個 boolean
可測試不依賴全域狀態直接 import config
可組合可以被其他 Hook 使用緊耦合特定元件

Feature Hook 模式

為每個功能模組建立一個 Hook,封裝該功能的所有邏輯:

function useUserManagement() {
  const users = useQuery({ queryKey: ['users'], queryFn: api.getUsers });
  const createUser = useMutation({ mutationFn: api.createUser });
  const deleteUser = useMutation({ mutationFn: api.deleteUser });
 
  return {
    users: users.data ?? [],
    isLoading: users.isLoading,
    createUser: createUser.mutate,
    deleteUser: deleteUser.mutate,
    isCreating: createUser.isPending,
  };
}

元件只需要:

function UserPage() {
  const { users, isLoading, createUser, deleteUser } = useUserManagement();
  // 元件只關心呈現,不關心資料怎麼來的
}

效能優化

渲染優化原則

React 的效能問題 90% 來自不必要的 re-render。

flowchart TD
    Render["元件 re-render"] --> Q1{"真的需要 re-render?"}
    Q1 -->|"State/Props 沒變"| Memo["React.memo\n阻止不必要的 re-render"]
    Q1 -->|"有變化"| Q2{"計算量大?"}
    Q2 -->|是| UseMemo["useMemo\n快取計算結果"]
    Q2 -->|否| OK["正常 re-render\n React 很快的"]

常見效能陷阱

陷阱 1:在 render 中建立新物件

// 每次 render 都建立新的 style 物件,導致子元件 re-render
<Child style={{ color: 'red' }} />
 
// 修正:提出去或用 useMemo
const style = useMemo(() => ({ color: 'red' }), []);
<Child style={style} />

陷阱 2:Context 值變化導致整棵樹 re-render

// 危險:每次 render 都建立新物件
<ThemeContext.Provider value={{ theme, toggleTheme }}>
 
// 修正:useMemo 穩定 value
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
<ThemeContext.Provider value={value}>

陷阱 3:在父元件中定義 callback

// 每次父元件 re-render 都建立新 function
<List items={items} onItemClick={(id) => handleClick(id)} />
 
// 修正:useCallback
const onItemClick = useCallback((id) => handleClick(id), [handleClick]);
<List items={items} onItemClick={onItemClick} />

不要過度優化

大多數情況下 React 的 re-render 足夠快。只在以下情況考慮優化:

  • 列表超過 100 項
  • 有明顯的卡頓(可用 React DevTools Profiler 確認)
  • 高頻更新(拖曳、動畫、即時輸入)

專案結構

Feature-Based 結構

src/
├── features/           # 按功能模組分
│   ├── auth/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api.ts
│   │   └── types.ts
│   ├── users/
│   │   ├── components/
│   │   ├── hooks/
│   │   ├── api.ts
│   │   └── types.ts
│   └── dashboard/
├── components/          # 共用 UI 元件
│   ├── Button.tsx
│   ├── Modal.tsx
│   └── Table.tsx
├── hooks/              # 共用 Hooks
│   ├── useDebounce.ts
│   └── useMediaQuery.ts
├── lib/                # 工具函數、API client
├── pages/              # 路由頁面(或 app/ in Next.js)
└── types/              # 共用型別

結構原則

原則說明
功能內聚相關的 component、hook、type 放一起
依賴方向features → components,不反向
扁平優先不要超過 3 層目錄嵌套
就近原則只有一個地方用的東西,放在使用者旁邊

何時重構結構

不要一開始就追求完美結構。以下是調整時機:

  • 同一個目錄超過 15 個檔案
  • 經常需要跨多個目錄修改才能完成一個功能
  • 新人需要超過 30 分鐘才能找到某個功能的程式碼

實務建議清單

元件設計

  • 元件超過 200 行就該拆分
  • Props 超過 7 個就該考慮重新設計
  • 一個元件只做一件事

狀態管理

  • Server State 用 React Query / SWR,不要自己管
  • 全域狀態越少越好,先問是否真的需要全域
  • URL 能表達的狀態就放 URL(搜尋、篩選、分頁)

效能

  • 先量測再優化,不要猜
  • React.memo 只用在確認有效能問題的元件
  • 長列表用虛擬化(react-window / react-virtuoso)

TypeScript

  • 元件 Props 用 interface,不用 type(有 extends 優勢)
  • API 回應型別和元件 Props 型別分開定義
  • 善用 as const 和 discriminated union

延伸閱讀