簡單介紹 useEffect
useEffect 是 react 裡面常常使用到到 hook 但是在使用上有很多需要注意的地方,希望可以在這一篇文章清楚的介紹 useEffect 的使用方法跟我知道的注意事項。
useEffect(setup, dependencies?)
setup
: 一個 function,當 react 呼叫 useEffect hook 的時候會執行這個 function,可以回傳一個 clean up function,當元件卸載或是 re-render 時,執行 useEffect 之前 會先執行 clean up function。
dependencies?
: 一個 array,放著 setup function 裡面使用到外部變數,當元件 re-render 時會使用 Object.is()
一個一個做比較,如果其中一個是 false 的時候會執行 clean up function 然後再執行 setup
function。
useEffect
。先用 console
來觀察一下 useEffect 的執行時機。
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("useEffect");
return () => {
console.log("clean up");
};
});
function addCount() {
setCount(count + 1);
}
console.log("component render");
return (
<div>
<h1>Count: {count}</h1>
{console.log("in jsx")}
<button onClick={addCount}>add name</button>
</div>
);
}
初次 render: component -> jsx -> useEffect
re-render: component -> jsx -> cleanup -> useEffect
可以注意到在初次 render 時 useEffect
是在 JSX render 完後才執行的,當 state 改變時也是在 JSX 都 render 完之後先執行 clean up 然後才執行 useEffect,看起來有點不太直覺。
useEffect 執行的時間是在 component 的 render 執行結束後才執行,因為 useEffect 的最常的用途就是用來同步元件的狀態或是取得外部的資源。
import { useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
這邊使用 react 官方提供的範例來說明。
在這一個 useEffect 裡面有兩個參數,第一個就是執行訂閱的 function ,第二個就是在 useEffect 裡面有使用到的變數,這個範例裡面是 serverUrl
跟 roomId
。
元件 re-render 時如果 serverUrl
跟 roomId
,不會改變,所以 useEffect 並不會再次執行。
先假設訂閱系統的 serverUrl
不會改變,會改變的只有 roomId
。
當 roomId
從 001 變成 002 時 useEffect 會先執行 clean up function 來取消訂閱,此時執行的是 舊的元件內的 clean up function 並不是新的,所以會取消訂閱 001 的 ChatRoom 然後重新訂閱 002 的 ChatRoom,並且此時 clean up function 裡面取消訂閱的 ChatRoom 也變成了 002。
props
跟 state
。props
跟 state
。如果沒有 clean up function 裡面沒有執行 disconnect
來取消舊的訂閱的話,就會發生當我切換到 002 的 ChatRoom 時 001 的訂閱尚未被取消,所以 server 還是會持續的更新 001 的資料造成資源浪費,那當我又從 002 切換成 001 時,又會再重新訂閱 001 但是 002 又沒有被取消,反而訂閱了兩次 001,所以當在 useEffect 執行訂閱行為時務必要在 clean up function 裡做取消的動作。
相似的情形還有 setInterval
或是 window.addEventListener
。
舉個例子,如果我設下計時器,並且希望每 0.5 秒我的 count 加 1,可能會這樣寫。
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
console.log("effect"); // 觀察 effect 執行
}, 500);
}, [count]);
return <h1>{count}</h1>;
}
會發現數字出現了不正常的跳動,而且 console 不斷地跑出非常大量的 effect。
這是因為我們沒有在 clean up function 裡面做取消訂閱的動作,所以每次 render 的時候 react 都會建立一個新的計時器,如下。
所以才會出現這個奇怪的狀況,在 cleanup function 加上取消訂閱就好了。
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
console.log("effect");
}, 500);
return () => {
timer && clearInterval(timer);
};
}, [count]);
務必要記得有訂閱事件時要在 cleanup function 裡面加上取消訂閱的動作。
另外如果在 useEffect 裡面進行 fetch 動作時會還有可能會有另外一個狀況,就是 water fall,簡單來說就是 api 回來的時間無法掌控。
這邊使用 react 官方提供的範例做說明。
假設有一個 select 選單,當用戶選擇時會取得當下那個人的 Bio 資料。
// app.tsx
import { useState, useEffect } from "react";
import { fetchBio } from "./api.js";
export default function Page() {
const [person, setPerson] = useState<string>("Alice");
const [bio, setBio] = useState<string | null>(null);
useEffect(() => {
setBio(null); // fetch 執行之前先設定成 null 以顯示 Loading...
fetchBio(person).then((result) => {
setBio(result);
});
}, [person]);
function handleSelect({ target }: React.ChangeEvent<HTMLSelectElement>) {
setPerson(target.value);
}
return (
<div>
<select value={person} onChange={handleSelect}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
<option value="Taylor">Taylor</option>
</select>
<hr />
<p>
<i>{bio ?? "Loading..."}</i>
</p>
</div>
);
}
假設當 fetch 的對象是 Bob 時會晚 3 秒才回應。
// api.tsx
export async function fetchBio(person: string): Promise<string> {
const delay = person === "Bob" ? 3000 : 200;
return new Promise((resolve) => {
setTimeout(() => {
resolve("This is " + person + "’s bio.");
}, delay);
});
}
會發現當你切到 Bob 然後再切換到別人的時候資料會出錯。
會發生這個狀況的原因很單純,因為我們不知道 api 的 response 什麼時候才會回來,如果比較晚才發送的 response 比先發送的 response 還要快就回來的話就會出現,先執行了新的 setState 之後又把執行了舊的 setState。
react 官方提供了一個簡單的解決方法,就是在 useEffect 的 cleanup function 裡面做狀態的控管。
useEffect(() => {
let ignore = false; // 狀態管理
setBio(null);
fetchBio(person).then((result) => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
};
}, [person]);
這樣就解決了!
上面有提到 useEffect 的 function 執行時會伴隨著新的 props 跟 state,cleanup function 執行時會伴隨著舊的 props 跟 state ,所以當從 Bob 切換成別人的時候會執行 Bob cleanup function,所以會把 Bob useEffect 裡面的 ignore = true;
即使 Bob 的 response 比較晚才回來也不會執行 setState 去改變當前的狀態。
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []);
}
以 react 官方的範例來看,明明在 useEffect 裡面有使用到 react 元件內的 serverUrl
跟 roomId
,但是沒有放在 dependencies 裡面,這樣會導致當 serverUrl
或 roomId
改變,需要重新執行訂閱,但是卻沒有執行。
如果 serverUrl
或 roomId
是不會改變的值,那可以不要放在 react 的元件內做宣告或是 state 管理,可以直接宣告在元件外。
const serverUrl = "https://localhost:1234";
const roomId = "001";
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []);
}
這樣 serverUrl
或 roomId
永遠不會改變,所以可以不用放在 dependencies 裡,但是當 dependencies 是空 array 時,代表你的 useEffect 只會執行一次,再之後 state 改變 re-render 時都不會再次執行。
Synchronizing with Effects - react document
下一篇簡單介紹 useRef。
如果內容有誤再麻煩大家指教,我會盡快修改。
這個系列的文章會同步更新在我個人的 Medium,歡迎大家來看看 👋👋👋
Medium