ReactのuseEffectについて理解を深める
ライフサイクルメソッドに馴染みのある自分は以下の説明がわかりやすかった
React のライフサイクルに馴染みがある場合は、useEffect フックを componentDidMount と componentDidUpdate と componentWillUnmount がまとまったものだと考えることができます。
useEffectのTips
useEffect は毎回のレンダー後に呼ばれるのか? その通りです!
初回のレンダー時および毎回の更新時に呼び出される
(componentDidMount + componentDidUpdate)
React は具体的には副作用のクリーンアップをいつ発生させるのか?
コンポーネントがアンマウントされるとき (componentWillUnmount) にクリーンアップを実行する
クリーンアップの関数は、useEffectの返り値に定義する
関心を分離するために複数の副作用を使う
実際に何をやっているのかに基づいてコードを分割できる。
useEffectは、副作用の内容によって定義を分けるのが良い。(1つのuseEffectにまとめすぎない)
もしも副作用とそのクリーンアップを 1 度だけ(マウント時とアンマウント時にのみ)実行したい場合は?
空の配列 ([]) を第 2 引数として渡すことで実現できる
副作用関数内で使われる関数は副作用関数内で宣言する
コンポーネントスコープ内のどの値が副作用の関数に依存しているかわかりやすくなる
Danさんの力作を読んだ
それぞれの render は独自のイベントハンドラを保持している
以下のイメージがわかりやすかった。要はレンダーごとに新しく関数を作成している。
特定の render の中では props と state は一生変わりません。
// 初期 render
function Counter() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + 0);
}, 3000);
}
// ...
<button onClick={handleAlertClick} />; // 0 が入ってるバージョン
// ...
}
// 押下されると、コンポーネント関数が呼び出される
function Counter() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + 1);
}, 3000);
}
// ...
<button onClick={handleAlertClick} />; // 1 が入ってるバージョン
// ...
}
// 再押下後、またコンポーネント関数が呼び出される
function Counter() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert("You clicked on: " + 2);
}, 3000);
}
// ...
<button onClick={handleAlertClick} />; // 2 が入ってるバージョン
// ...
}
それぞれの render は、独自のエフェクトを保持している
以下のイメージがわかりやすい。
// 初期 render
function Counter() {
// ...
useEffect(
// 初期 render のエフェクト関数
() => {
document.title = `You clicked ${0} times`;
}
);
// ...
}
// 押下されると、コンポーネント関数が呼び出される
function Counter() {
// ...
useEffect(
// 2回目の render のエフェクト関数
() => {
document.title = `You clicked ${1} times`;
}
);
// ...
}
レンダー関数の様子
- 各 state の値でUIをレンダーする
- 初回の結果:<p>You clicked 0 times</p>.
- 次のエフェクトを実行する
- 初回の実行: () => { document.title = 'You clicked 0 times' }
- エフェクトは、レンダーの後にそのレンダー関数内のstateやpropsを利用して副作用を起こす
ここで一番言いたいのは、おそらく以下の内容
props か state を「早めに」コンポーネント内で呼ぼうが呼ばまいが関係ありません。 なぜなら変わらないからです!一つの render 内のスコープでは、 props と state は変わりません。(分割代入するとさらに分かりやすいです。)
未来の props や state を読みたいということは React の流れに逆らっているというのを用心してください
常に最新のデータが欲しい場合は、refを使って更新することができる
function Example() {
const [count, setCount] = useState(0);
const latestCount = useRef(count);
useEffect(() => {
// mutable な最新の値をセットする
latestCount.current = count;
setTimeout(() => {
// mutable な最新の値を読む
console.log(`You clicked ${latestCount.current} times`);
}, 3000);
});
// ...
では Cleanup はどうでしょう?
cleanupは、新しいpropsやstateでのレンダーが終了した後に発火する。
レンダー前には発火しないことで、スクリーンアップデートをブロックすることを防いでいる。
- Reactが新しいpropsやstateのUIをrender する
- Reactが古いpropsやstateのエフェクトをcleanup する
- Reactが新しいpropsやstateのエフェクトを実行する
ライフサイクルではなく、シンクロ
レンダーのタイミングに依存するような処理をuseEffectで書くことは、React的ではないからやめたほうが良い。
初期 render か否かで違う挙動をするエフェクトを書こうとしてる場合は、React の流れに逆らっています! もし、結果が 過程 に頼ってしまっている場合は、シンクロに失敗しています。
依存関係に正直になる二つの方法
useEffectの第二引数は、再レンダーごとに毎回エフェクトを実行するのを防ぐ仕組み。
第二引数が、同一の値であると判断されれば、エフェクトは実行されない。
第二引数にわたす値は(依存関係は)、なるべく減らすのが好ましい。減らす際には、useStateの場合には以下のようなTipsを使うこともできる。
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // これはuseState(coutn + 1) と同じ
}, 1000);
return () => clearInterval(id);
}, []);
さらに処理が複雑になる場合は、useReducerを使うことを勧めている。
コンポーネント側ではactionを飛ばすだけにして、処理自体はreducerに任せる感じ。
関数を利用する際には以下の方法を取るとよいとしている。
素直に関数を第二引数にわたすだけでは、レンダーごとに関数は新しく作られるので、
エフェクトは毎回実行されることになる。
- なるべくuseEffect内に副作用を起こす関数を書く
- コンポーネント外に関数を定義する
- useCallbackでメモ化する
レースコンディションについて
見慣れたfetchしてリストをセットするコンポーネント。
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id);
}
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
これは、リクエストの順番が担保されていないので、バグの可能性がある。
例えば、 {id: 10} をフェッチしていて {id: 20} に変更してそのリクエストが先に返ってきた場合、最初にリクエストして後から終わった処理は state を不正に上書きしてしまいます。
こういう状況をレースコンディションと呼ぶらしい。
useEffectだとcleanup処理をすることで、バグを回避できる。
(厳密にはクラスコンポーネントでも、componentWillUnmountで処理を書けば回避できるが書きやすさが違うということを言いたいっぽい)
useEffect(() => {
let didCancel = false;
async function fetchData() {
const article = await API.fetchArticle(id);
if (!didCancel) { // cleanup後は呼ばれない
setArticle(article);
}
}
fetchData();
return () => {
didCancel = true;
};
}, [id]);
その他の議論
どうすれば複数のコールバックを深く受け渡すのを回避できますか?
大きなコンポーネントツリーにおいて我々がお勧めする代替手段は、useReducer で dispatch 関数を作って、それをコンテクスト経由で下の階層に渡す、というものです