EP.16Web 開發系列

useEffect 不是「生命週期」
——是「監聽資料變化」

資料請求、副作用、Cleanup——用三個真實場景搞懂 useEffect, React 最常讓初學者困惑的 Hook。

Joseph Chen 2026 14 min read React · useEffect · Hook · 副作用 · 資料請求

「我知道 useState 了。但我想在頁面載入時呼叫 API 拿資料,該怎麼做? 為什麼不能直接在 Component 裡呼叫 fetch?」

這篇解釋清楚 useEffect——React 最常讓初學者困惑的 Hook。 不從「定義」開始,直接從你遇到的問題開始。

為什麼不能直接 fetch?

很直覺的想法:「我要資料,就直接呼叫 fetch 啊。」來試試看:

UserProfile.tsx — ❌ 這樣寫有問題
// ❌ 這樣寫會造成無限迴圈!
function UserProfile() {
  const [user, setUser] = useState(null);

  // 直接在 Component 函式裡呼叫 fetch
  fetch('/api/user')
    .then(res => res.json())
    .then(data => setUser(data));  // setUser → 重新渲染 → 又呼叫 fetch → 無限迴圈!

  return <div>{user?.name}</div>;
}

為什麼會無限迴圈?

React Component 是一個函式,每次狀態改變都會重新執行這個函式。 如果你在函式裡直接 fetch,就會觸發這個死循環:

Component 渲染執行 fetchsetUser(data)重新渲染又執行 fetch ♾️

你需要一個方式告訴 React:「等你把 UI 渲染完,再來做 fetch 這件事,而且只做一次。」 這就是 useEffect 的設計目的。

副作用(Side Effect)是什麼

「副作用」聽起來嚇人,其實很簡單

Component 的「主業」是根據資料回傳 UI(就是那個 return 裡的 JSX)。 任何不是「回傳 UI」的事情,都叫副作用(Side Effect):

  • 呼叫 API、發 HTTP 請求
  • 設定計時器(setTimeout、setInterval)
  • 訂閱事件(WebSocket、EventEmitter)
  • 修改 document.title
  • 操作瀏覽器的 localStorage

useEffect 就是說:「等你把 UI 渲染完,再來做這些副業。」

useEffect 的三種用法

這是最重要的部分。useEffect 的行為完全由第二個參數(dependency array)決定:

用法 1

只在「第一次渲染」執行(最常用)

useEffect with empty array — 初始資料請求
function UserList() {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    // 在組件第一次出現在畫面上時執行
    fetch('/api/users')
      .then(res => res.json())
      .then(data => setUsers(data));
  }, []);  // ← 空陣列 = 只執行一次,不再重複

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

空陣列 [] 的語義是:「沒有任何 dependency,所以只在最初執行一次,之後不管什麼改變都不重跑。」

用法 2

當特定資料改變時執行

useEffect with dependency — 監聽特定 state
function UserProfile() {
  const [userId, setUserId] = useState(1);
  const [user, setUser] = useState(null);

  useEffect(() => {
    // 當 userId 改變時,重新請求對應的使用者資料
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);  // ← userId 是 dependency,userId 變了就重新執行

  return (
    <div>
      <button onClick={() => setUserId(2)}>切換使用者</button>
      <p>{user?.name}</p>
    </div>
  );
}

這個模式超級常用:「當你選了不同的使用者 ID,就重新抓那個使用者的資料」。 useEffect 監聽 userId,它一改變,副作用就重新執行。

用法 3

每次渲染都執行(幾乎不用)

useEffect without array — 每次渲染都跑
useEffect(() => {
  console.log('每次渲染都執行');
  // ⚠️ 小心!如果這裡有 setState,很容易造成無限迴圈
});  // ← 沒有第二個參數

幾乎不會用到這個形式。一旦 effect 裡有 setState,就會:執行 effect → setState → 重新渲染 → 執行 effect → 無限迴圈。

三種寫法對比

寫法執行時機使用場景
useEffect(() => {}, [])只執行一次(初次渲染後)初始化資料請求
useEffect(() => {}, [x])x 改變時執行依賴某個 state / prop
useEffect(() => {})每次渲染後都執行幾乎不用

完整的資料請求模式

真實專案裡,資料請求不只是 fetch 那麼簡單。你還需要處理「載入中」狀態和「錯誤」狀態—— 否則使用者盯著空白頁面等,根本不知道發生什麼事:

UserProfile.tsx — 完整的 loading + error 處理
interface User {
  id: number;
  name: string;
  email: string;
}

interface UserProfileProps {
  userId: number;
}

function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);   // 載入中狀態
  const [error, setError] = useState<string | null>(null);  // 錯誤狀態

  useEffect(() => {
    // 每次 userId 改變,重置狀態並重新請求
    setIsLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error(`API 回傳錯誤:${res.status}`);
        return res.json();
      })
      .then((data: User) => {
        setUser(data);
        setIsLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setIsLoading(false);
      });
  }, [userId]);  // userId 改變 → 重新請求

  // 三種狀態對應三種 UI
  if (isLoading) return <p>載入中...</p>;
  if (error) return <p>發生錯誤:{error}</p>;
  if (!user) return null;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

初始狀態

isLoading: true

使用者進入頁面,還沒拿到資料。顯示 spinner 或骨架屏,告訴使用者「正在載入」。

錯誤狀態

error: string

API 掛了、網路斷線、認證失敗。顯示有意義的錯誤訊息,不要讓使用者面對空白。

成功狀態

user: User

資料拿回來了,正常渲染。這才是你的「主要 UI」,但它只是三種狀態之一。

這個模式在 React 專案裡無處不在

isLoading + error + data 是最基本的非同步資料模式。 之後你會學到 React Query(TanStack Query),它把這整個模式包裝成一行 const { data, isLoading, error } = useQuery(...), 原理是一樣的,只是幫你把樣板程式碼都處理掉了。

Cleanup——為什麼有些 useEffect 要「收拾」

報紙訂閱的比喻

想像你訂了一份報紙。你搬家了,但沒有通知報社取消訂閱—— 報紙一直送到舊地址,浪費資源,也可能引發奇怪的問題(例如舊鄰居收到你的報紙)。

Cleanup 就是「搬家前先取消訂閱」。 當 Component 從畫面消失時,你要負責清除你啟動的副作用, 不然那些副作用會繼續跑,繼續嘗試更新已經不存在的 Component。

WebSocket + Cleanup
function LiveChat() {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    // 訂閱 WebSocket
    const ws = new WebSocket('wss://api.example.com/live');

    ws.onmessage = (event) => {
      setMessages(prev => [...prev, event.data]);
    };

    ws.onerror = (error) => {
      console.error('WebSocket 錯誤', error);
    };

    // ← return 一個函式 = Cleanup
    // 當 Component 從畫面消失時,React 會自動呼叫這個函式
    return () => {
      ws.close();  // 關閉 WebSocket 連線,不再接收訊息
      console.log('WebSocket 已關閉');
    };
  }, []);  // 只建立一次連線

  return (
    <ul>
      {messages.map((msg, i) => <li key={i}>{msg}</li>)}
    </ul>
  );
}
計時器 + Cleanup
function CountdownTimer() {
  const [count, setCount] = useState(60);

  useEffect(() => {
    // 建立計時器,每秒 -1
    const timer = setInterval(() => {
      setCount(c => c - 1);
    }, 1000);

    // Cleanup:Component 消失時清除計時器
    return () => {
      clearInterval(timer);  // 不清除的話,計時器會繼續跑
      // 如果你切換了頁面,計時器還在背後跑,繼續嘗試更新已不存在的 Component
      // React 會警告:Can't perform a React state update on an unmounted component.
    };
  }, []);

  return <p>倒數:{count}</p>;
}

什麼時候需要 Cleanup?

需要 Cleanup

  • ✓ setInterval / setTimeout
  • ✓ WebSocket 連線
  • ✓ EventListener 訂閱
  • ✓ 第三方 library 的訂閱
  • ✓ 正在進行中的 fetch(可用 AbortController)

不需要 Cleanup

  • ✓ 一次性的 fetch 請求
  • ✓ 修改 document.title
  • ✓ 寫入 localStorage
  • ✓ 記錄 log

三個最常見的錯誤

學會用法之後,這三個錯誤幾乎每個初學者都會犯。早點認識它們,少走彎路:

錯誤 1:忘記 Dependency Array → 無限迴圈

tsx
// ❌ 忘記第二個參數,每次渲染都執行
useEffect(() => {
  setCount(count + 1);
  // setCount → 重新渲染 → 執行 effect → setCount → 無限迴圈!
});

// ✅ 加上 dependency array
useEffect(() => {
  // 只有需要時才執行
  document.title = `計數:${count}`;
}, [count]);  // count 改變時才更新 title

如果你的 effect 只需要執行一次,用空陣列 []。 如果依賴某個值,把那個值放進 dependency array。

錯誤 2:Dependency Array 不完整 → 資料過時(Stale Closure)

tsx
// ❌ 用了 userId,但沒放進 dependency array
useEffect(() => {
  fetch(`/api/users/${userId}`)  // userId 可能是舊的!
    .then(res => res.json())
    .then(data => setUser(data));
}, []);  // ← 空陣列,userId 改變時不重新執行

// ✅ 把 userId 加進 dependency array
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));
}, [userId]);  // ← userId 改變時重新執行

ESLint 救你一命

安裝 eslint-plugin-react-hooks, 規則 react-hooks/exhaustive-deps 會提醒你 把所有在 effect 裡用到的值都加進 dependency array。Next.js 預設已啟用。

錯誤 3:在 useEffect 直接用 async → 回傳了 Promise

tsx
// ❌ useEffect 的 callback 不能是 async function
// async function 回傳 Promise,但 useEffect 的 callback 應該回傳 cleanup function 或 void
useEffect(async () => {
  const res = await fetch('/api/data');  // 語法上可以,但有問題
  const data = await res.json();
  setData(data);
}, []);

// ✅ 在 effect 裡面定義 async 函式,然後呼叫它
useEffect(() => {
  async function fetchData() {
    try {
      const res = await fetch('/api/data');
      if (!res.ok) throw new Error('Failed');
      const data = await res.json();
      setData(data);
    } catch (err) {
      setError(err.message);
    }
  }

  fetchData();  // 呼叫它
}, []);

這是 React 文件明確提到的規則:useEffect 的 callback 必須是同步函式。 如果要用 async/await,就在 callback 裡定義一個 async 函式,然後呼叫它。

本篇重點回顧

01不能在 Component 函式裡直接 fetch,會造成無限迴圈(渲染 → fetch → setState → 渲染 → ...)。
02副作用是「不是回傳 UI 的事情」:API 請求、計時器、事件訂閱。useEffect 讓你在渲染完之後才做這些事。
03三種用法:空陣列(只跑一次)、有 dependency(對應值改變時跑)、無陣列(每次渲染跑,幾乎不用)。
04完整的資料請求要處理三種狀態:isLoading、error、data。這是 React 非同步的標準模式。
05WebSocket、計時器等需要 Cleanup:return 一個函式,Component 消失時 React 自動呼叫它。
06三個常見錯誤:忘記 dependency array、dependency array 不完整、直接用 async callback。

掌握了 useEffect,你就打通了 React 資料請求的任督二脈。 下一篇進入 State 管理模式——當 useState 不夠用時,你需要什麼?

React
useEffect
Hook
副作用
資料請求
EP.16