55
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

【useEffect】初回にも実行されて困るなら《何をキッカケに、どう更新されるか》を見直せ

Last updated at Posted at 2024-06-26

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]);

useEffect暴発.gif

期待する動作: (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 に搭載される機能と関係するようです。)

❌️StrictModeで動かない
  const isFirstRef = useRef(true);

  useEffect(() => {
    if(isFirstRef.current) {
      isFirstRef.current = false;
      return;
    }
    setUsaPrice(Math.floor(japanPrice / 100));
  }, [japanPrice]);

じゃあ何すか

React を書くエンジニアは「謎の教義」に苦しめられるって事すか

いいえ、解決方法はあります。しかも、useEffect よりもきっと明確になります。

✅️ イベントハンドラを、きちんと書いてあげよう

useEffectjapanPrice という ステートの変化を監視するのではなく

仕様をそのまんま素直に解釈して、「日本での販売価格入力の input」 の onChange イベントハンドラの中で、setUsaPrice(japanPrice / 100) するだけ で良いのです。

PriceForm コンポーネントが保持している japanPriceusaPrice というステートが、《何をキッカケに、どう更新されるか》 が明確になったと思います。

"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 公式ドキュメント日本語版

段落区切りの追加は筆者

(japanPrice, usaPrice) の直積をステートとする状態機械の図のようなもの

この状態遷移図のようなものでは、

  • 赤い矢印 #DC6363
    • 「日本での販売価格」への《入力時》の状態遷移
  • 青い矢印 #5D6BED
    • 「アメリカでの販売価格」への《入力時》の状態遷移

でそれぞれ表しています。

つづき: やっぱりエフェクトが必要な場合

とは言っても、今回は《ステートと外部の同期》という本来の用途に当てはまるケースだから、イベントハンドラには移せないよ

という場合もあるでしょう。

そのような場合については別の記事で解説しました。

おまけ: 参考になる記事紹介

React 公式ドキュメントの記事です。「何をきっかけに、どう更新するか」を整理するための概念である「ステートマシン」のアイデアを使って、インタラクションを記述するプロセスを、やさしく噛み砕いて説明してくれています。


「useEffect は、本来の目的にしたがったとき、どのような動きをするのか」説明した私の記事です。動画もあるので分かりやすいと思います。

55
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
55
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?