本文 AI 產出,尚未審核

React + TypeScript:useState / useEffect 的型別推論與實務應用


簡介

在 React 開發中,useStateuseEffect 是兩個最常被使用的 Hook。加入 TypeScript 後,我們不只可以得到編譯時的錯誤檢查,還能透過 型別推論 讓程式碼更安全、更具可讀性。
如果對型別推論的機制不夠熟悉,常會出現「狀態值變成 any」或「依賴陣列錯誤」的情況,導致執行時錯誤難以追蹤。本文將從概念說明、實作範例到常見陷阱與最佳實踐,完整介紹在 React + TypeScript 專案裡如何正確使用 useStateuseEffect 的型別推論。

目標讀者:具備基本 React 與 JavaScript 知識的開發者,想要在 TypeScript 環境下寫出更可靠的 Hook 程式碼。


核心概念

1. useState 的型別推論原理

useState 的型別宣告如下(簡化版):

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
  • S 為狀態值的型別,會根據傳入的 initialState 自動推斷。
  • initialState函式(lazy initializer),則推論的型別是函式回傳值的型別。

1.1 基本推論

import { useState } from "react";

function Counter() {
  // 依據初始值 0,TypeScript 推論出 number
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

重點:只要初始值不是 nullundefined,TypeScript 能正確推論出具體型別(此例為 number)。

1.2 推論為 undefinednull 時的解法

function UserProfile() {
  // 初始值為 null,推論出 null,導致後續 setState 只能接受 null
  const [user, setUser] = useState(null); // user: null

  // 正確做法:使用型別參數明確指定
  const [user2, setUser2] = useState<User | null>(null);
}

技巧:當初始值可能是 null / undefined 時,顯式提供型別參數 (useState<Type | null>(null)) 能避免 null 變成唯一型別。

1.3 使用泛型推論的「懶初始化」

function ExpensiveComponent() {
  // 初始值透過函式產生,僅在第一次渲染時執行
  const [data, setData] = useState(() => {
    const raw = fetchDataFromLocalStorage(); // 回傳 string[]
    return raw;
  });

  // data 被推論為 string[]
}

說明:若傳入的是「返回值」的函式,TypeScript 會根據函式的回傳型別推論 S

2. useEffect 的型別推論與依賴陣列

useEffect 的簽名:

function useEffect(effect: EffectCallback, deps?: DependencyList): void;
type EffectCallback = () => (void | (() => void | undefined));
type DependencyList = ReadonlyArray<unknown>;
  • deps 陣列的型別是 ReadonlyArray<unknown>不會自動推論具體型別,這是設計讓開發者自行決定依賴內容。
  • 若省略 deps,Effect 會在每一次渲染後執行;若傳入空陣列 [],則只在掛載與卸載時執行。

2.1 正確寫法:依賴變數的型別安全

function SearchBox() {
  const [query, setQuery] = useState("");
  const [result, setResult] = useState<string[]>([]);

  useEffect(() => {
    // query 為 string,型別安全
    const timer = setTimeout(() => {
      fetchResults(query).then(setResult);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]); // 只依賴 query
}

要點:依賴陣列只放 直接使用 的變數,避免放入 函式或物件(除非使用 useCallback / useMemo 包裝),避免不必要的重新執行。

2.2 使用 useRef 來避免依賴過多

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    intervalRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []); // 只在掛載時建立一次
}

說明intervalRef 的型別被明確寫為 NodeJS.Timeout | null,避免 any 隱藏錯誤。

3. 結合 useStateuseEffect 的型別協同

function WeatherWidget({ city }: { city: string }) {
  const [weather, setWeather] = useState<Weather | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    getWeather(city)
      .then(data => setWeather(data))
      .finally(() => setLoading(false));
  }, [city]); // city 型別為 string,安全

  if (loading) return <p>載入中…</p>;
  if (!weather) return <p>無法取得天氣資訊</p>;

  return (
    <div>
      <h3>{city} 天氣</h3>
      <p>{weather.description}, {weather.temp}°C</p>
    </div>
  );
}
  • weather 使用 聯合型別 Weather | null,使得 null 狀態(尚未取得資料)被明確處理。
  • loading 為布林值,直接推論為 boolean

程式碼範例(實用篇)

以下示範 5 個常見情境,說明如何在 useState / useEffect 中利用型別推論避免錯誤。

範例 1:表單欄位的動態狀態

type FormValues = {
  name: string;
  age: number;
  agree: boolean;
};

function Form() {
  // 初始值使用完整物件,型別自動推論為 FormValues
  const [values, setValues] = useState<FormValues>({
    name: "",
    age: 0,
    agree: false,
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, type, checked, value } = e.target;
    setValues(prev => ({
      ...prev,
      [name]: type === "checkbox" ? checked : type === "number" ? Number(value) : value,
    }));
  };

  return (
    <form>
      <input name="name" value={values.name} onChange={handleChange} />
      <input name="age" type="number" value={values.age} onChange={handleChange} />
      <label>
        <input name="agree" type="checkbox" checked={values.agree} onChange={handleChange} />
        同意條款
      </label>
    </form>
  );
}

重點:使用 useState<FormValues> 明確告訴 TypeScript 「狀態是一個固定結構的物件」,在 handleChange 中若寫錯屬性名稱會立刻得到編譯錯誤。


範例 2:從 API 取得資料,使用 null 為未載入狀態

type Post = {
  id: number;
  title: string;
  body: string;
};

function PostDetail({ postId }: { postId: number }) {
  const [post, setPost] = useState<Post | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
      .then(r => r.json())
      .then(setPost)
      .catch(err => setError(err.message));
  }, [postId]);

  if (error) return <p>錯誤:{error}</p>;
  if (!post) return <p>載入中…</p>;

  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
    </article>
  );
}
  • postPost | null必須先檢查 null 才能安全存取屬性,避免執行時 undefined 錯誤。

範例 3:使用 useRef 追蹤上一個狀態值(型別推論)

function CounterWithPrev() {
  const [count, setCount] = useState(0);
  const prevRef = useRef<number>(0); // 明確型別

  useEffect(() => {
    prevRef.current = count; // 每次渲染後更新
  }, [count]);

  return (
    <div>
      <p>目前:{count}</p>
      <p>上一次:{prevRef.current}</p>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}
  • useRef<number>prevRef.current 永遠是 number,不會因為第一次渲染時 undefined 而出錯。

範例 4:懶初始化與大型陣列的型別推論

function LargeList() {
  // 假設 fetchLargeData() 回傳 number[]
  const [list] = useState(() => fetchLargeData()); // list 被推論為 number[]

  return (
    <ul>
      {list.map(item => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}
  • 只要 fetchLargeData 正確回傳 number[]useState 會自動推論 listnumber[],不需要額外寫 useState<number[]>

範例 5:依賴函式的型別安全(useCallback + useEffect

function Search({ api }: { api: (q: string) => Promise<string[]> }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);

  const fetch = useCallback(
    (q: string) => {
      api(q).then(setResults);
    },
    [api] // 只在 api 變更時重新產生
  );

  useEffect(() => {
    if (query) fetch(query);
  }, [query, fetch]); // fetch 已是 memoized,安全依賴

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
    </>
  );
}
  • useCallback 的回傳型別為 (q: string) => voiduseEffect 依賴此函式時不會因為「函式每次重新建立」而導致無限迴圈。

常見陷阱與最佳實踐

陷阱 可能的錯誤 解決方案
初始值為 nullundefined useState(null) 會推論成 null,後續 setState 只能接受 null 顯式提供型別:`useState<Type
直接在 useEffect 內使用未列入依賴的變數 依賴不完整導致 stale closure(舊的變數值) 列入所有直接使用的變數,或使用 useCallback / useMemo 包裝
依賴陣列中放入物件或陣列 每次渲染都會因為引用不同而重新執行 Effect 使用 useMemo 產生穩定的參考,或改寫為 深度比較(慎用)
setState 的回傳值被誤用 setState(prev => prev + 1) 需要返回新值;若返回 void 會報錯 始終回傳正確型別,尤其在泛型 SetStateAction<S>
useRef 未指定型別 變成 MutableRefObject<any>,失去型別安全 使用 useRef<Type>(initialValue) 明確型別,如 useRef<number>(0)

最佳實踐清單

  1. 盡量讓 TypeScript 自動推論:只要不涉及 null / undefined,直接傳入初始值即可。
  2. 需要 null 時,使用聯合型別useState<Type | null>(null)
  3. Effect 依賴請保持最小:只放使用到的變數或 memoized 函式。
  4. 懶初始化:大型或耗時的初始值,使用 useState(() => ...),同時確保回傳型別正確。
  5. 保持 setState 的純函式:若使用回傳函式,務必回傳新狀態,避免副作用。

實際應用場景

1. 表單驗證與即時回饋

在大型表單中,每個欄位的驗證結果都會存於 useState。使用 聯合型別string | null)與 映射型別Record<field, string | null>)可以在編譯期捕捉錯誤,並在 useEffect 中根據驗證結果觸發 API 呼叫。

type Errors = Record<"email" | "password", string | null>;

function LoginForm() {
  const [values, setValues] = useState({ email: "", password: "" });
  const [errors, setErrors] = useState<Errors>({ email: null, password: null });

  useEffect(() => {
    // 當 errors 全部為 null 時才送出請求
    const hasError = Object.values(errors).some(e => e !== null);
    if (!hasError) submitLogin(values);
  }, [errors, values]);
}

2. WebSocket 或長連線的生命週期管理

useEffect 配合 useRef 可以安全地在掛載時建立連線,卸載時關閉,且不會因為渲染而重複建立。

function ChatRoom() {
  const socketRef = useRef<WebSocket | null>(null);
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    socketRef.current = new WebSocket("wss://example.com/chat");
    socketRef.current.onmessage = e => setMessages(m => [...m, e.data]);

    return () => socketRef.current?.close();
  }, []); // 只在第一次掛載時執行
}

3. 依賴外部函式的資料快取

透過 useCallback 包裝 API 呼叫,並在 useEffect 中依賴此函式,可避免因父層重新渲染而觸發不必要的請求。

function ProductList({ fetchProducts }: { fetchProducts: (page: number) => Promise<Product[]> }) {
  const [page, setPage] = useState(1);
  const [list, setList] = useState<Product[]>([]);

  const load = useCallback(
    (p: number) => fetchProducts(p).then(setList),
    [fetchProducts]
  );

  useEffect(() => {
    load(page);
  }, [page, load]);
}

總結

  • useState 會根據 初始值 自動推論型別;若初始值是 null / undefined,須 顯式提供型別
  • useEffect 的依賴陣列不具型別推論,開發者必須自行確保 依賴完整且最小,避免不必要的重新執行。
  • 懶初始化聯合型別useRef 的明確型別 以及 useCallback 的配合,是提升型別安全與效能的關鍵技巧。
  • 在實務開發中,將型別推論與 最佳實踐(如依賴最小化、錯誤處理、生命週期管理)結合,可大幅降低執行時錯誤,提升程式碼可讀性與維護性。

掌握了 useState / useEffect 的型別推論,你就能在 React + TypeScript 專案中寫出安全、可預測、易維護的程式碼。祝開發順利,寫出更好的 React 應用!