iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Modern Web

現代函式庫與JavaScript的關係系列 第 19

Day 19 | 別被無限循環 hooks 牽著走 — 從 dependency array 到 zustand 狀態管理

  • 分享至 

  • xImage
  •  

三個提問

  1. 為什麼 useEffect 一直重複執行?我明明寫對依賴陣列了啊
  2. 在 React 應用裡傳資料為什麼這麼困難?Prop drilling 地獄什麼時候才能終結?
  3. Redux 太複雜、Context API 又會觸發過度渲染,有沒有更簡潔的狀態管理方案?

為什麼沒用好 hooks 會讓你陷入地獄

React hooks(2019 年推出)讓函式式元件擁有了狀態和生命週期。但它帶來的不只是自由,還有陷阱。

陷阱清單:

  • useEffect 無限循環
  • 依賴陣列的「我以為」vs「實際」
  • Prop drilling(從父層一層層傳屬性給子層)
  • Stale closure(useCallback 困境)
  • 全局狀態該怎麼管

你以為只要寫對括號就行,實際上你在玩一個很細微的遊戲:hook深度使用閉包陷阱也來自於閉包、依賴追蹤、渲染時序


第一部分:useEffect 無限循環的四個死法

死法一:依賴陣列中的物件每次都「不等於」

先看一個完整的例子:

// 父元件
function App() {
  const [userId, setUserId] = useState('user-123');
  
  // ❌ 問題在這裡:每次 App render,都會新建一個 user 物件
  const user = { id: userId, name: '阿比', gender: 'M', address: '台北' };
  
  return (
    <>
      <button onClick={() => setUserId('user-456')}>切換到不同的人</button>
      <button onClick={() => /* 其他不相關的 setState */}>其他按鈕</button>
      <MyComponent user={user} />
    </>
  );
}

// 子元件
function MyComponent({ user }) {
  const [profile, setProfile] = useState(null);

  useEffect(() => {
    console.log('fetch profile for', user.id);
    fetch(`/api/profiles/${user.id}`)
      .then(r => r.json())
      .then(setProfile);
  }, [user]);  // ← 危險!user 是物件參考
}

useEffect 的依賴陣列用什麼來比較?

React 對依賴陣列的每個元素使用 Object.is() 來比較:

// React 內部簡化版本
function useEffect(callback, deps) {
  const prevDeps = /* 上一次的依賴 */;
  
  let shouldRun = false;
  for (let i = 0; i < deps.length; i++) {
    // 對每個依賴,用 Object.is() 比較
    if (!Object.is(deps[i], prevDeps[i])) {
      shouldRun = true;
      break;
    }
  }
  
  if (shouldRun) {
    callback();
  }
}

Object.is() 怎麼比較?

// 對於基本型別:比較「值」
Object.is('user-123', 'user-123')  // true ✅
Object.is('user-123', 'user-456')  // false

// 對於物件:比較「參考(記憶體地址)」,不比較內容
const user1 = { id: 'user-123', name: '阿比' };
const user2 = { id: 'user-123', name: '阿比' };  // 內容完全相同

Object.is(user1, user1)   // true (同一個物件參考)
Object.is(user1, user2)   // false (不同物件,不同記憶體地址) ← 這是關鍵!
Object.is(user1.id, user2.id)  // true (都是 'user-123',值相同)

所以無限循環發生在這裡:

第一次 render:
  ├─ App 執行 → 新建 user 物件,記憶體地址 0x1234
  ├─ MyComponent 收到 user (0x1234)
  ├─ useEffect 執行 → fetch 資料
  └─ setProfile → re-render

第二次 render:
  ├─ App 執行 → 新建新的 user 物件,記憶體地址 0x5678 ← 新地址!
  ├─ MyComponent 收到 user (0x5678)
  ├─ useEffect 比較:Object.is(user(0x1234), user(0x5678)) = false
  ├─ useEffect 認為 user「改變了」,重新執行
  ├─ fetch 資料 → setProfile
  └─ re-render... 無限迴圈

為什麼每次都新建物件?
  因為 JavaScript 的語法:const user = { ... } 
  每執行一次,就在記憶體裡建立一個新物件
  這不是 React 的問題,是 JavaScript 基本特性

✅ 解決方案一:只依賴 user.id(最簡單,最常用)

useEffect(() => {
  fetch(`/api/profiles/${user.id}`);
}, [user.id]);  // ← 字串型別,Object.is() 比較的是值

// 實際行為:
// - 第一次 render:user.id = 'user-123' → fetch
// - App 其他按鈕被按,但 userId 沒改 → user.id 還是 'user-123'
// - useEffect 比較:Object.is('user-123', 'user-123') = true
// - useEffect 不執行 ✅

✅ 解決方案二:如果多個屬性都影響結果,列出所有需要的

// 場景:profile 內容要根據 gender 和 address 也不同
useEffect(() => {
  fetch(`/api/profiles/${user.id}?gender=${user.gender}&address=${user.address}`);
}, [user.id, user.gender, user.address]);

// 實際行為:
// - 只有當 user.id、user.gender、user.address 中的任何一個改變時,才執行
// - user 的其他屬性改變不會觸發
// - 比 [user] 更精確,避免不必要的 fetch

✅ 解決方案三:如果非得用整個 user 物件,用 useMemo 穩定它

function App() {
  const [userId, setUserId] = useState('user-123');
  const [gender, setGender] = useState('M');
  
  // 用 useMemo 保證:只有當依賴改變時,才建立新的 user 物件
  const user = useMemo(
    () => ({ id: userId, gender }),
    [userId, gender]  // ← 只有這兩個改變時,才新建 user 物件
  );
  
  return <MyComponent user={user} />;
}

function MyComponent({ user }) {
  useEffect(() => {
    fetch(`/api/profiles/${user.id}`);
  }, [user]);  // 現在可以用 [user] 了,因為 user 的參考更穩定
  
  // 實際行為:
  // - App 的其他 state 改變,user 物件的參考不變
  // - useEffect 比較:Object.is(user, user) = true
  // - useEffect 不執行 ✅
}

最佳實踐:選擇最精確的依賴

情況 寫法 為什麼
只根據 user.id fetch [user.id] 精確、避免無限循環、避免不必要的 fetch
根據多個屬性決定結果 [user.id, user.gender] 列出真正需要的屬性
父元件已用 useMemo 穩定 [user] 可以用 user 物件,因為參考已經穩定
需要監聽 user 的所有改變 [user.id, user.gender, user.address, ...] 列出所有相關屬性

一句話記住:Object.is() 對物件比的是參考,所以如果物件來自普通的 JavaScript 賦值(不是 useMemo),每次都會是新參考。直接依賴基本型別更安全。

死法二:setState 觸發的新物件

// ❌ 無限循環
const MyComponent = () => {
  const [config, setConfig] = useState({ apiUrl: 'http://localhost:8080' });

  useEffect(() => {
    const newConfig = { ...config, retries: 3 };
    // 這裡修改了 config,但 config 本身也在依賴陣列裡...
    setConfig(newConfig);
  }, [config]);  // ← 自己改自己,無限迴圈
};

// ✅ 修正:分離關切點
const MyComponent = () => {
  const [apiUrl, setApiUrl] = useState('http://localhost:8080');
  const [retries, setRetries] = useState(0);

  useEffect(() => {
    // 初始化邏輯,只執行一次
  }, []);  // ← 空依賴陣列
};

死法三:useCallback 又新增了 setState

// ❌ 無限循環
const MyComponent = () => {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(count + 1);  // ← 閉包陷阱:count 被凍結在這裡
  }, [count]);  // ← 要加 count 才能看到最新值

  useEffect(() => {
    const timer = setInterval(increment, 1000);
    return () => clearInterval(timer);
  }, [increment]);  // ← increment 每次都改(因為 count 改)→ useEffect 重新執行
};

// ✅ 修正:用函式形式的 setState
const MyComponent = () => {
  const [count, setCount] = useState(0);

  const increment = useCallback(() => {
    setCount(prev => prev + 1);  // ← 不需要依賴 count
  }, []);  // ← 依賴陣列永遠是空的

  useEffect(() => {
    const timer = setInterval(increment, 1000);
    return () => clearInterval(timer);
  }, [increment]);  // ← increment 永遠不變
};

死法四:外部庫的物件被迫加入依賴陣列

// ❌ 無限循環
const MyComponent = () => {
  const router = useRouter();  // ← router 物件每次都新建
  const theme = useTheme();    // ← theme 物件每次都新建

  useEffect(() => {
    // 做什麼事...
  }, [router, theme]);  // ← 外部物件,永遠「改變」
};

// ✅ 修正一:只依賴你真正需要的部分
useEffect(() => {
  // ...
}, [router.pathname, theme.colors.primary]);

// ✅ 修正二:把外部物件包在自己的 hook 裡
export const useRouterPath = () => {
  const router = useRouter();
  const [path, setPath] = useState(router.pathname);

  useEffect(() => {
    setPath(router.pathname);
  }, [router.pathname]);

  return path;
};

第二部分:Prop Drilling 地獄 — 為什麼層層傳資料這麼痛

假設你有這樣的元件結構:

App
  ├─ Dashboard
  │   ├─ Sidebar
  │   │   ├─ UserCard
  │   │   │   └─ UserAvatar ← 只有這裡需要 user 資料
  │   └─ MainContent
  └─ Header

你想讓 UserAvatar 拿到用戶資料,結果:

// ❌ Prop drilling 地獄
const App = () => {
  const [user, setUser] = useState(null);

  return (
    <Dashboard user={user}>
      <Sidebar user={user}>
        <UserCard user={user}>
          <UserAvatar user={user} />
        </UserCard>
      </Sidebar>
    </Dashboard>
  );
};

const Dashboard = ({ user, children }) => <div>{children}</div>;
const Sidebar = ({ user, children }) => <div>{children}</div>;
const UserCard = ({ user, children }) => <div>{children}</div>;
const UserAvatar = ({ user }) => <img src={user.avatar} />;

// 問題:
// 1. user 要經過 4 層元件才能到達 UserAvatar
// 2. Dashboard, Sidebar, UserCard 都要宣告 user prop,但根本不用它
// 3. 中間層如果 re-render,UserAvatar 也會跟著 re-render(即使 user 沒改)

傳統方案一:Context API

// ✅ 解決 prop drilling,但可能觸發過度渲染
const UserContext = createContext();

const App = () => {
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={user}>
      <Dashboard>
        <Sidebar>
          <UserCard>
            <UserAvatar />
          </UserCard>
        </Sidebar>
      </Dashboard>
    </UserContext.Provider>
  );
};

const UserAvatar = () => {
  const user = useContext(UserContext);
  return <img src={user.avatar} />;
};

// 問題:
// - UserContext.Provider 的 value={user} 是物件參考
// - user 一改,所有訂閱 UserContext 的元件都會 re-render
// - 即使只改了 user.email,訂閱 user.avatar 的元件也會重新算一遍
// - 這就是 Context API 的「過度渲染問題」

傳統方案二:Redux

// ✅ 完全解決,但程式碼量是 Context API 的 3 倍
import { createSlice, configureStore } from '@reduxjs/toolkit';

const userSlice = createSlice({
  name: 'user',
  initialState: null,
  reducers: {
    setUser: (state, action) => {
      return action.payload;
    },
  },
});

const store = configureStore({
  reducer: {
    user: userSlice.reducer,
  },
});

// 元件裡
const UserAvatar = () => {
  const user = useSelector(state => state.user);
  return <img src={user.avatar} />;
};

// Redux 優勢:
// 1. useSelector 做了「記憶化」:只要回傳值沒變,就不會觸發元件 re-render
// 2. DevTools 可以追蹤每個 action,時間旅行 debug
// 3. 中間層不用知道 user 的存在

// Redux 劣勢:
// - reducer / action / selector 的 boilerplate 太多
// - 初學者會被 dispatch + thunk 搞暈

第三部分:現代解決方案 — Zustand

Zustand(德文「狀態」)是一個輕量級的狀態管理庫。相比 Redux,它:

  • 少 10 倍的程式碼
  • 沒有 boilerplate
  • 天然支援「按需選取狀態」(自動記憶化)
  • DevTools 也有
  • TypeScript 友好

基本用法

import { create } from 'zustand';

// ✅ 建立 store
const useUserStore = create((set) => ({
  user: null,
  setUser: (newUser) => set({ user: newUser }),
  updateEmail: (email) => set((state) => ({
    user: { ...state.user, email },
  })),
  logout: () => set({ user: null }),
}));

// 元件裡
const UserAvatar = () => {
  // 寫法一:整個 store
  const store = useUserStore();
  
  // 寫法二:選取需要的部分(自動記憶化)
  const user = useUserStore(state => state.user);
  const setUser = useUserStore(state => state.setUser);
  
  return <img src={user?.avatar} />;
};

記憶化對比

// Redux
const user = useSelector(state => state.user);
// useSelector 內部會檢查回傳值是否改變,沒改就不 re-render

// Zustand
const user = useUserStore(state => state.user);
// 同樣的機制:selector function 回傳的值沒改,就不 re-render

兩者在這點上原理相同。差別是:

// Redux:要寫 action
const dispatch = useDispatch();
// 之後在 component 裡
dispatch(setUser(newUser));

// Zustand:直接呼叫 function
const setUser = useUserStore(state => state.setUser);
setUser(newUser);

進階:useShallow 避免不必要的 re-render

// 假設 user 是個大物件
// user = { id, name, email, avatar, role, preferences, ... }

// ❌ 有問題:只要 user 物件改變,就會 re-render,即使 avatar 沒改
const user = useUserStore(state => state.user);

// ✅ 只比較淺層屬性
import { useShallow } from 'zustand/react';

const user = useUserStore(
  useShallow(state => ({
    avatar: state.user.avatar,
    name: state.user.name,
  }))
);

// useShallow 會檢查 avatar 和 name 有沒有改
// 只要兩個都沒改,就不會觸發這個元件的 re-render

實戰例子:訂單管理系統

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

const useOrderStore = create(
  devtools(
    persist(
      (set, get) => ({
        // 狀態
        orders: [],
        selectedOrder: null,
        filters: { status: 'all', vendor: null },
        
        // Actions
        fetchOrders: async (eventId) => {
          const res = await fetch(`/api/events/${eventId}/orders`);
          const data = await res.json();
          set({ orders: data });
        },
        
        selectOrder: (orderId) => {
          const order = get().orders.find(o => o.id === orderId);
          set({ selectedOrder: order });
        },
        
        updateOrderStatus: async (orderId, status) => {
          // 樂觀更新(先改本地,再問後端)
          set(state => ({
            orders: state.orders.map(o =>
              o.id === orderId ? { ...o, status } : o
            ),
          }));
          
          // 發送到後端
          await fetch(`/api/orders/${orderId}`, {
            method: 'PATCH',
            body: JSON.stringify({ status }),
          });
        },
        
        setFilter: (filterKey, value) => set(state => ({
          filters: { ...state.filters, [filterKey]: value },
        })),
        
        // Computed:可以用 get() 組合出新資料
        filteredOrders: () => {
          const { orders, filters } = get();
          return orders.filter(o => {
            if (filters.status !== 'all' && o.status !== filters.status) {
              return false;
            }
            if (filters.vendor && o.vendor_id !== filters.vendor) {
              return false;
            }
            return true;
          });
        },
        
        getOrderStats: () => {
          const { orders } = get();
          return {
            total: orders.length,
            pending: orders.filter(o => o.status === 'pending').length,
            completed: orders.filter(o => o.status === 'completed').length,
          };
        },
      }),
      {
        name: 'order-store',  // localStorage key
      }
    ),
    { name: 'OrderStore' }  // DevTools 名稱
  )
);

// 元件使用
const OrderList = () => {
  // 按需選取,自動記憶化
  const orders = useOrderStore(state => state.filteredOrders());
  const filters = useOrderStore(state => state.filters);
  const setFilter = useOrderStore(state => state.setFilter);

  return (
    <div>
      <Filter value={filters.status} onChange={(v) => setFilter('status', v)} />
      {orders.map(order => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  );
};

const OrderStats = () => {
  // 只依賴 stats,orders 改但 stats 沒改就不會 re-render
  const stats = useOrderStore(state => state.getOrderStats());
  
  return (
    <div>
      <p>Total: {stats.total}</p>
      <p>Pending: {stats.pending}</p>
      <p>Completed: {stats.completed}</p>
    </div>
  );
};

第四部分:什麼時候該用什麼

情況 推薦方案
小型應用,狀態少於 3 個 useState 就夠了
多層元件需要共享狀態,但不太複雜 Context API(如果不怕 re-render 問題)
複雜應用,需要追蹤每個動作,有時間旅行 debug 需求 Redux + Redux DevTools
複雜應用,但討厭 Redux 的 boilerplate Zustand(推薦)
SSR / Server Components 時代 Server-side state(useServer 之類)
實時協作應用(多人同時編輯) Yjs / CRDT + Zustand

第五部分:React 19 時代的狀態管理走向

React 19(預計 2025 年正式發布)帶來了:

1. use() 函式 — 在元件頂層用 await

// ✅ React 19
const UserProfile = ({ userPromise }) => {
  const user = use(userPromise);  // 可以在頂層 await Promise
  return <div>{user.name}</div>;
};

這簡化了「fetch 資料然後顯示」的流程。

2. Server Components — 狀態在後端管理

// ✅ Server Component(預設)
export default async function OrderList() {
  const orders = await db.orders.findMany();  // 在伺服器上 fetch
  return <div>{orders.map(...)}</div>;
}

// ❌ 需要互動時才用 Client Component
'use client';
import { useOrderStore } from '@/store';

const OrderFilters = () => {
  const filters = useOrderStore(state => state.filters);
  // ...
};

Server Components 讓狀態更接近資料源(資料庫),而不是往上推到全局狀態。

3. Actions — Server Mutations

// ✅ Server Action
async function updateOrderStatus(orderId: string, status: string) {
  'use server';
  await db.orders.update(orderId, { status });
}

// Client Component 使用
'use client';
const OrderRow = ({ order }) => {
  const [pending, setPending] = useState(false);

  const handleStatusChange = async (status) => {
    setPending(true);
    await updateOrderStatus(order.id, status);  // 直接呼叫伺服器函式
    setPending(false);
  };

  return <button onClick={() => handleStatusChange('completed')}>完成</button>;
};

這消除了「fetch -> JSON 往返 -> dispatch」的冗長流程。


總結:三個層級的狀態管理演進

Layer 1(本地):useState
  ↓
Layer 2(跨元件):Context API / Zustand
  ↓
Layer 3(全球應用):Redux(複雜場景)/ Server State(現代)

現在最務實的選擇:

  1. 小應用:useState + Context API
  2. 中等應用:Zustand(簡潔、好用)
  3. 大型應用:Zustand + Server Components(React 19+)
  4. 複雜業務邏輯:考慮 Redux(但先試試 Zustand)

下一篇預告

Day 20:「API 呼叫永遠在賽跑 — React Query / TanStack Query 怎麼管理遠端資料狀態」

你會看到為什麼說「遠端資料狀態 ≠ 應用狀態」,以及 React Query 怎麼替你省去 loading / error / cache 的重複程式碼。


上一篇
Day 18:實戰 150+ commits 的三大編譯陷阱 — TypeScript 編譯錯誤、環境變數管理、React 記憶體洩漏
下一篇
Day 20 | API 呼叫永遠在賽跑 — React Query/TanStack Query 怎麼管理遠端資料狀態
系列文
現代函式庫與JavaScript的關係22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言