React + TypeScript:useState / useEffect 的型別推論與實務應用
簡介
在 React 開發中,useState 與 useEffect 是兩個最常被使用的 Hook。加入 TypeScript 後,我們不只可以得到編譯時的錯誤檢查,還能透過 型別推論 讓程式碼更安全、更具可讀性。
如果對型別推論的機制不夠熟悉,常會出現「狀態值變成 any」或「依賴陣列錯誤」的情況,導致執行時錯誤難以追蹤。本文將從概念說明、實作範例到常見陷阱與最佳實踐,完整介紹在 React + TypeScript 專案裡如何正確使用 useState 與 useEffect 的型別推論。
目標讀者:具備基本 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>;
}
重點:只要初始值不是
null或undefined,TypeScript 能正確推論出具體型別(此例為number)。
1.2 推論為 undefined 或 null 時的解法
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. 結合 useState 與 useEffect 的型別協同
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>
);
}
post為Post | 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會自動推論list為number[],不需要額外寫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) => void,useEffect依賴此函式時不會因為「函式每次重新建立」而導致無限迴圈。
常見陷阱與最佳實踐
| 陷阱 | 可能的錯誤 | 解決方案 |
|---|---|---|
初始值為 null 或 undefined |
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) |
最佳實踐清單
- 盡量讓 TypeScript 自動推論:只要不涉及
null/undefined,直接傳入初始值即可。 - 需要
null時,使用聯合型別:useState<Type | null>(null)。 - Effect 依賴請保持最小:只放使用到的變數或 memoized 函式。
- 懶初始化:大型或耗時的初始值,使用
useState(() => ...),同時確保回傳型別正確。 - 保持
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 應用!