useEffect を使って「初回以外の再レンダリング時に実行される処理」を書くにはどうすれば良いのか?
という疑問を、たまに目にします。
たとえば、以下のような仕様の、「商品価格を編集する画面」を作ることを考えてみましょう。
- 日本での販売価格入力
- ページ読み込み時には初期値が入っている
- アメリカでの販売価格入力
- ページ読み込み時には、初期値が入っている
- 日本での販売価格入力に入力するたび、その1/100の値が自動で入力される
- あくまで、入力の参考にするため、というイメージ
- この欄に入力すれば、上書きできる
そもそも、こういった自動入力は「入力するたび」ではなく、「フォーカスを外したとき」にするのが定石だと思いますが、
useEffect の話がしたいので、あえてこのような仕様に設定しています。
❌️ useEffect を使って変更検知しようとすると暴発する
《日本での販売価格 japanPrice
が変わるときに、アメリカでの販売価格 usaPrice
が変化するから useEffect
を使う》…これで良いでしょうか?
答えは NO です。実際の挙動を見てみましょう。
export const PriceForm: FC<Props> = ({ initialValues, action }) => {
const [japanPrice, setJapanPrice] = useState(initialValues.japanPrice);
const [usaPrice, setUsaPrice] = useState(initialValues.usaPrice);
useEffect(() => {
setUsaPrice(Math.floor(japanPrice / 100));
}, [japanPrice]);
期待する動作: (2,024円, 17ドル) という価格なので、その価格がフォームの初期値としてそのままセットされほしい
実際の動作: フォームの初期値がセットされた直後に、アメリカ販売価格が17ドル → 20ドルのように更新されてしまう!(※上の動画では、わざと動作を遅くしています)
そんなはずじゃなかったのに~😭
❌️ useEffect には「初回」も「二回目以降」もない
そこであなたはこう考えたかも知れません。
マウント時には実行されへんかったらエエんや!
そういうフックもあるやろ!
残念ながら、React には 「初回のレンダリング(つまりマウント)時は実行せず、再レンダリング時にのみ実行される」というフックが提供されていません。
- React のクラスコンポーネント
componentDidUpdate
- Vue
-
watch()
(非 immediate)
-
は持っている機能なのに、関数コンポーネントが提供してくれないのはなぜでしょうか?
公式ドキュメントを見れば、その理由がわかります。
エフェクトの開始/終了という 1 サイクルのみにフォーカスしてください。コンポーネントがマウント中なのか、更新中なのか、はたまたアンマウント中なのかは問題ではありません。
https://ja.react.dev/learn/lifecycle-of-reactive-effects#thinking-from-the-effects-perspective
リアクティブなエフェクトのライフサイクル | React 公式ドキュメント日本語版
とあるように、エフェクトには「開始→終了」のサイクルの繰り返ししかなく、それが「初回」なのか「2回目以降なのか」も〈あえて〉分からないように設計されているからです。
では、ref を使って「2回目以降か」を判定すれば動くでしょうか?これも NO です。
StrictMode を使用している場合、開発環境では不必要なエフェクトを 1 回多く実行して、本番環境ではそうしません。
React は StrictMode をこのように使って、「 1 サイクルのみにフォーカスする」という教義に従わない、《いま何回目か》に依存してしまっているコードを、積極的に炙り出すのです。
(Activity と呼ばれる、将来 React に搭載される機能と関係するようです。)
const isFirstRef = useRef(true);
useEffect(() => {
if(isFirstRef.current) {
isFirstRef.current = false;
return;
}
setUsaPrice(Math.floor(japanPrice / 100));
}, [japanPrice]);
じゃあ何すか
React を書くエンジニアは「謎の教義」に苦しめられるって事すか
いいえ、解決方法はあります。しかも、useEffect よりもきっと明確になります。
✅️ イベントハンドラを、きちんと書いてあげよう
useEffect
で japanPrice
という ステートの変化を監視するのではなく、
仕様をそのまんま素直に解釈して、「日本での販売価格入力の input」 の onChange イベントハンドラの中で、setUsaPrice(japanPrice / 100)
するだけ で良いのです。
PriceForm
コンポーネントが保持している japanPrice
と usaPrice
というステートが、《何をキッカケに、どう更新されるか》 が明確になったと思います。
"use client";
import { type ChangeEventHandler, useState, type FC } from "react";
import styles from "./price-form.module.css";
type Props = {
action: (formData: FormData) => Promise<void>,
initialValues: {
japanPrice: number,
usaPrice: number,
},
};
export const PriceForm: FC<Props> = ({ initialValues, action }) => {
const [japanPrice, setJapanPrice] = useState(initialValues.japanPrice);
const [usaPrice, setUsaPrice] = useState(initialValues.usaPrice);
const handleChangeJapanPrice: ChangeEventHandler<HTMLInputElement> = (e) => {
const japanPrice = Number.parseInt(e.target.value);
setJapanPrice(japanPrice);
setUsaPrice(Math.floor(japanPrise / 100));
};
const handleChangeUsaPrice: ChangeEventHandler<HTMLInputElement> = (e) => {
setUsaPrice(Number.parseInt(e.target.value));
};
return (
<form className={styles.root} action={action}>
<div>
<label>
日本での販売価格(円)
<input
type="number"
name="japanPrice"
value={japanPrice}
onChange={handleChangeJapanPrice}
/>
</label>
</div>
<div>
<label>
アメリカでの販売価格(ドル)
<input
type="number"
name="usaPrice"
value={usaPrice}
onChange={handleChangeUsaPrice}
/>
</label>
</div>
<div>
<button type="submit">更新する</button>
</div>
</form>
);
};
このようなパターンについては、公式ドキュメントに記載されているので、そちらも読んでみることをオススメします。
ちなみに、ステートを更新する部分で const japanPrice = ...; setJapanPrice(japanPrice)
のように、二度手間のような書き方になっているのは、React のステートの仕様に対応するためです。
const handleChangeJapanPrice: ChangeEventHandler<HTMLInputElement> = (e) => {
// ❌️これで動きそうだけど、古い japanPrice の値に基づいて usaPrice を計算してしまうのでダメ
setJapanPrice(Number.parseInt(e.target.value));
setUsaPrice(Math.floor(japanPrise / 100));
};
ちょうどその話題について説明して記事があるので、こちらもどうぞ。
まとめ
今回のような複雑なインタラクションを、無理に useEffect で実装するのは、ピタゴラ装置を組み立てるようなものです。
- ロジックが複雑すぎて、「ただし、〇〇のときは実行されたくない」が出てくるな…
- useEffect って、セットアップ→クリーンアップの流れのはずだけど、そうじゃないな…
という場合には、いちど立ち止まって、
「コンポーネントの状態を、何をキッカケに、どう遷移させるのか」をじっくり考えてみると良いかもしれません。
ユーザイベントの処理にエフェクトは必要ありません。(中略)
購入ボタンのクリックイベントハンドラでは、何が起こったかが正確にわかります。
エフェクトが実行される時点では、ユーザが 何をした のか(例えば、どのボタンがクリックされたのか)はもうわかりません。
したがって、通常は対応するイベントハンドラでユーザイベントを処理するべきです。
https://ja.react.dev/learn/you-might-not-need-an-effect
そのエフェクトは不要かも | React 公式ドキュメント日本語版
段落区切りの追加は筆者
この状態遷移図のようなものでは、
- 赤い矢印
#DC6363
- 「日本での販売価格」への《入力時》の状態遷移
- 青い矢印
#5D6BED
- 「アメリカでの販売価格」への《入力時》の状態遷移
でそれぞれ表しています。
つづき: やっぱりエフェクトが必要な場合
とは言っても、今回は《ステートと外部の同期》という本来の用途に当てはまるケースだから、イベントハンドラには移せないよ
という場合もあるでしょう。
そのような場合については別の記事で解説しました。
おまけ: 参考になる記事紹介
React 公式ドキュメントの記事です。「何をきっかけに、どう更新するか」を整理するための概念である「ステートマシン」のアイデアを使って、インタラクションを記述するプロセスを、やさしく噛み砕いて説明してくれています。
「useEffect は、本来の目的にしたがったとき、どのような動きをするのか」説明した私の記事です。動画もあるので分かりやすいと思います。