はじめに
あるコンポーネントで複数のuseEffectフックで、色々と処理を行おうとすると思わぬバグに出会うことがあります。
そこで今回は
- 副作用について
- useEffectについて
- 僕が出会ったバグ
の順番で、useEffectの理解を深めると共に、useEffectを複数書くことでバグが生じた例を紹介したいと思います。
環境
- Node.js v18.16.1
- react v18.2.0
- react-dom v18.2.0
- react-scripts v5.0.1
副作用とは
まずはuseEffectを理解するために、関数、および副作用について理解をしておきましょう。
Reactのコンポーネントは関数です。
プログラミングでの意味の関数ではなく、数学的な意味で、「引数が同じなら戻り値も常に同じ」という特性のことです。
y = 2xという関数は、xに3を与えたら必ず6が返ってきますね。
一方で、stateの更新や外部サーバへの接続といった処理は副作用と呼ばれることが多いです。
これらの処理は、引数が同じであっても、戻り値が異なる可能性があるため、レンダー中に行ってはいけません。
例
例えば、追加ボタンを押すことでメモのリストに追加できるコンポーネントがあるとします。
ここで{ title: "メモ1" }
を引数として渡して、コンポーネントを返してもらう状況を考えましょう。
この場合だと、追加ボタンを押すたびにメモ1が増えていって、メモの総数はどんどん増えていってしまいます。
引数は同じなのに戻り値が異なるため、これは副作用が生じていると言えます。
useEffectとは?
公式の説明を読んでみるとこのような一節があります。
エフェクトは、特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのものです。
どういうことでしょう?
基本はイベントハンドラ
先ほどの「追加ボタンを押すことでメモのリストに追加できる」という処理について考えてみましょう。
この処理は、ユーザーがボタンを押すことで生じる副作用です。
ユーザーの画面操作によって生じる副作用は、イベントハンドラに任せましょう。
const handleAddNewMemoButtonClick = () => {
const newMemo = { id: crypto.randomUUID(), content: "新規メモ" };
setAllMemos([...allMemos, newMemo]);
};
レンダーによって引き起こされる副作用とは
こちらも公式の説明を見てみると例が載っています。
チャットでのメッセージ送信は、ユーザが特定のボタンをクリックすることによって直接引き起こされるため、イベントです。しかし、サーバ接続のセットアップは、コンポーネントが表示される原因となるインタラクションに関係なく行われるべきであるため、エフェクトです。
トップページにアクセスしたら自動でサーバに接続される処理は、副作用であるもののユーザーの操作によって引き起こされる訳ではありません。
このような処理はuseEffectに任せます。
useEffectはレンダーの後に行われる
エフェクトは、コミットの最後に、画面が更新された後に実行されます。
とまたまた公式に書いてある通り、エフェクトはレンダーの後に実行されます。
なので、下記のようなエフェクトは全く意味がありません。
const [memos, setMemos] = useState([]);
useEffect(() => {
setMemos([]);
}, []);
なぜなら、
- useStateでmemosに
[]
をセットする - Reactが初期レンダーを行う
- レンダー後にエフェクトがmemosに
[]
をセットする
と、既にmemosに[]
をセットしているのに、エフェクトで再度同じ初期値をセットしようとしているからです。
アプリケーション初期化はエフェクトではないので注意しましょう。
useEffectを複数使うと...
ここで実際にバグが起きた悪いエフェクトの使い方を見てみましょう。
const [memos, setMemos] = useState([]);
// 初期レンダー後にlocal storageのメモを読み込み、memosにセットする
useEffect(() => {
const memosJson = localStorage.getItem("memos");
if (memosJson === null) return;
setMemos(JSON.parse(memosJson));
}, []);
// memosが更新されたらlocal storageに保存する
useEffect(() => {
localStorage.setItem("memos", JSON.stringify(memos));
}, [memos]);
各エフェクトは下記のような処理をしています。
- 初回レンダー後に、local storageからデータを読み取り、memosにセットする。
- memosに更新があった場合、local storageには最新のmemosを保存します。
local storageとの接続は副作用に属します。
また、ユーザーの操作によるものではなく、アプリを開いた時点でメモの読み込みと表示をしてほしいので、エフェクトで処理するのは理にかなってると言えるでしょう。
バグ発生!?
実際にこのアプリを動かしてみましょう。下図のようなメモを用意しておきました。
全てメモが消えてしまいました。
原因
実はエフェクトの処理は非同期的に実行されるので、下記のような処理の順番でエフェクトが実行されたことがバグの原因です。
- const [memos, setMemos] = useState([]);で初期値に空配列をセット。
- レンダー
- 空配列がセットされたallMemosを利用して「storageへの保存」をするエフェクトが先に処理を終える。
- その後、「storageからの読み込み」をするエフェクトが行われる。3で空配列保存しちゃってるから、データが消えたように見える。
解決策
「storageへの保存」はレンダーによって引き起こされるのではなく、ユーザーが保存ボタンを押したときに保存されるのが一般的ではないでしょうか?
ユーザー操作によって引き起こされる副作用なので、イベントハンドラで制御をしてあげましょう。
const handleSaveButtonClick = () => {
if (formText.trim() === "") {
alert("保存するメモの内容を書いてください。");
return;
}
const updatedMemos = memos.map((memo) =>
memo.id === editingMemo.id ? { ...memo, content: formText } : memo,
);
setMemos(updatedMemos);
localStorage.setItem("memos", JSON.stringify(updatedMemos));
};