🌊

【React】ReducerのActionで処理が2回実行される理由

2024/02/25に公開4

Next.jsで開発をしていると、なぜか処理が2回実行されていることがありました。
これは困る!と思い色々調べてみたのですが、問題発生〜解決までの流れをまとめている記事が少なかったので自分でまとめてみました。
個人の解釈が入っているので、ここは間違えてるよ!という部分があればぜひご指摘いただけると嬉しいです🙏

Reducerをざっくり説明

Reactアプリケーションで状態管理を行うための機能です。
基本的にuseReducerフックを使います。
reducerと呼ばれる関数の中に、ステートに変更を加えるための処理が並列で定義されており、
それぞれの処理をアクションと呼びます。
TODOリストを例にするとこんな感じ。

function reducer(state, action) {
  switch (action.type) {
    case "add_todo": {処理}
    case "edit_todo": {処理}
    case "delete_todo": {処理}
  }
}

グローバルなステートを定義するためのuseContext フックと組み合わせることで、
アプリケーション全体で、ステートの状態管理を共有できたりもします。
https://ja.react.dev/reference/react/useReducer

Reducerを使った状態管理を試してみる

ではReducerを使って、ボタンを押すたびに年齢を+1するアクションを定義してみましょう!

年齢を+1するアクションを定義する

Reducerに定義したロジックはこちら。
stateには年齢を格納するageが定義されています。

function reducer(state: State, action: Action) {
  switch (action.type) {
    case "up_age": {
      state.age++;
      return { ...state, age: state.age };
    }
  }
}

画面に描画する情報がこちら。

<div>私は{state.age}歳です</div>
<button
  onClick={() => {
    dispatch({ type: "up_age" });
  }}
>
  年齢 ++
</button>

ボタンを押したら「私は{年齢}歳です」が1ずつカウントアップされていく想定の実装です。

実行してみます。

ぬ?なんか2ずつカウントアップしている。

こんな恐ろしいボタンを放置しておくわけにはいかないので、ひとまずカウントアップしている付近にログを仕込んで動きを確認してみます。

case "up_age": {
  console.log("年齢を1つ上げます"); // ログ
  state.age++;
  return { ...state, age: state.age };
}

…1クリックごとに、処理が2回実行されてる???😇

2回実行されるのは、ReactのStrictModeの仕業

なぜ処理が2回実行されるのかというと、
結論、ReactのStrictModeという機能が働いているためです。
https://ja.react.dev/reference/react/StrictMode

<StrictMode> は、開発環境においてコンポーネントの一般的なバグを早期に見つけるのに役立ちます。

StrictModeはかなりややこしいのですが、抑えるべきポイントは以下の3点

  • 2回実行は開発環境でのみ機能するため、本番環境には影響しない
  • バグを早期に見つけ出すための機能
  • 機能をOFFにする方法もある

つまりReactは、早期にバグを見つけ出すために開発環境のみであえて処理を2回実行しているのです。
ありがた迷惑😇ややこしいからさっさとOFFにしちゃおう😇

そう思いましたが、無理やりOFFにするのもなんか違和感があるのでもう少し深掘りしてみました。

StrictModeによって2回実行される理由

2回実行される理由は「早期にバグを見つけ出すため」と説明しました。
具体的にはバグを見つけ出すために、どのように有効に機能しているのでしょうか?

ここからは純粋関数という考え方を抑えておく必要があります。
Reactは基本的にReducer内の処理を純粋関数で定義することを推奨しています。
純粋関数でない場合に、今回のように意図しない挙動になり
早期にバグの可能性を潰すことができるわけですね。

では純粋関数をもう少し詳しく深掘りしてみましょう。

純粋関数とは

https://ja.react.dev/learn/keeping-components-pure#purity-components-as-formulas

この記事でわかりやすく紹介されています。
要は、与えられた引数が同じであれば、返ってくる結果も同じでなければならないという考え方です。

純粋な例

このコードを見てみましょう。

function Cup(guest) {
  return <h2>カップ#{guest}人分必要だよ!</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup guest={1} />
    </>
  );
}

Cupコンポーネントにguest={1}を渡すと、必ずカップ1人分必要だよ! が返ってきます。
2でも3でも同様です。

4を渡したらカップ10人分必要だよ!になってしまう。なんてことはありません。
<Cup guest={1} />を2回記述したらカップ2人分必要だよ!になってしまう。なんてこともありません。

必ず渡した値に対して、同じ結果が返ってきます。

純粋ではない例

では次にこちらのコードを見てみましょう。

let guest = 0;

function Cup() {
  guest = guest + 1;
  return <h2>カップ#{guest}人分必要だよ!</h2>;
}

export default function TeaSet() {
  return (
    <>
      <Cup />
      <Cup />
      <Cup />
    </>
  );
}

このコードでは、複数回呼び出すと、呼び出した回数分+1された値が返ってきます。
呼び出した回数などに左右されるので、この関数は「純粋ではない」ということになります。

StrictModeの話に戻ります。
先ほどログを仕込んで分かった通り、Reducerの中で処理が2回実行されていましたね。
今の純粋関数の話と合わせると、
処理が複数回実行された時に、予期せぬ結果が返ってきたら不純な状態だよ😇直してね😇
というのがStrictModeの役割です。

先ほどの不純なカウントアップ処理を、純粋にしてみる

では先ほどの処理に戻ります。
なぜこのロジックが2回実行された時に、2回++されてしまうのでしょうか?

function reducer(state: State, action: Action) {
  switch (action.type) {
    case "up_age": {
      state.age++;
      return { ...state, age: state.age };
    }
  }
}

答えはReact公式のドキュメントに書いていました。
https://ja.react.dev/reference/react/useReducer#my-reducer-or-initializer-function-runs-twice

純粋でないリデューサ関数はステート内の配列を書き換えています
...
配列の書き換えではなく置き換えを行うことでミスを修正できます

基本的にステート内の配列を処理中に書き換えてはいけないのです。
どこでステートの配列を書き換えているのかというと、ここです。

state.age++;

ステートは決まったタイミングで更新されるべきであり、
useStateの場合はsetState、useReducerの場合はreturnが適切な更新タイミングです。
それにもかかわらず、returnの前にstate.age++;するのは、ステートをイレギュラーなタイミングで書き換えてしまっていることになるわけです。

そのため、ステートの書き換えてはなく、returnするタイミングで配列を置き換えてあげる必要があります。
正しいコードはこうです。

function reducer(state: State, action: Action) {
  switch (action.type) {
    case "up_age": {
      return { ...state, age: state.age++ };
    }
  }
}

returnの前にステートが書き換えられるようなことはなく、
returnのタイミングで、現在の値に+1した値に置き換えていますね。

さらに理解を深めたい方はこちらのドキュメントも読んでみてください!

https://ja.react.dev/reference/react/useReducer#my-reducer-or-initializer-function-runs-twice

React は 2 回の呼び出しのうちの 1 つの結果を使用し、もう 1 つの結果は無視します。
コンポーネント、イニシャライザ、およびリデューサ関数が純粋である限り、これはロジックに影響を与えません。
ただし、これらの関数が誤っていて不純である場合、これによりミスに気付くことができます。

Reducerを使い倒せ!

使い倒せ💥💥💥💥

Discussion

Honey32Honey32

失礼します。以下のコードについてですが、

function reducer(state: State, action: Action) {
  switch (action.type) {
    case "up_age": {
      return { ...state, age: state.age++ };
    }
  }
}

これは、「ステートを更新するタイミングを適正にしたから正しく動いた」というのは正確ではありません。

そのコードでは古いオブジェクト(state)を上書きしていますが、新しいオブジェクトを作成して return しています。このとき、古いオブジェクトではなく新しいオブジェクトが新しいステートとしてセットされたから正しく動いた、と理解すると正確です。

古いオブジェクトを書き換えることをやめた訳では無いので、依然としてこれは意図しない動作(《useEffect の引数としてこのオブジェクトを列挙したとき、再発火すべきなのに、再発火しない》といった現象)を引き起こす危険な書き方です。

以下のセクションが参考になると思います。(useState について述べられていますが、useReducer も同じような性質を持っているので、そのまま適用できます。)

https://ja.react.dev/learn/updating-objects-in-state#treat-state-as-read-only

オムそばオムそば

ご指摘ありがとうございます!いただいた記事も拝見しました。
retrunのタイミングで++しても、古いステートを上書きしていることには変わりないんですね。。

古いオブジェクトを書き換えることをやめた訳では無い

state.age++をしている限りは、上書きになってしまうという理解であっていますでしょうか?

その場合以下のように、Stateの中身を別オブジェクトとして切り離すことで問題は解決されますでしょうか?
もし他に良い解決方法があればご教示いただけますと幸いです!

case "up_age": {
  const copyState = { ...state };
  copyState.age++;
  return { ...state, age: copyState.age };
}

よろしくお願いいたします。

Honey32Honey32

はい。提示されたコードで正しいです!


state.age++をしている限りは、上書きになってしまうという理解であっていますでしょうか?

はい。 state.age++ を実行したあとに console.log(state) を使うと確認できますが、state オブジェクト自体の age プロパティの値が 1 だけ増えます。

const state = { age: 0 };
state.age++
console.log(state); // { age: 1 } と表示される

また、そもそも状態を変えるインクリメント演算子を使わず、状態を変えない + 1 を使って以下のようにコードを書くことも多いです。

case "up_age": {
  return { ...state, age: state.age + 1 };
}

あと、僕のはじめの指摘でも、重要なバグを見落としていました💦。

よく確かめてみると、元のコードだと「rerducer 関数が2回実行されることで、たまたま正しく動いているだけなので、本番ビルドでは状態が更新されません

以下のように書けば、元のコードの意味を変えずにコンソール出力を差し込むことができます。

    case "up_age": {
      const r = { count: state.age++ };
      console.log(r);
      return r;
    }

これでボタンを押してコンソールを確認すると、

  • age が 0 のときにボタンを押下
  • → { age: 0 } と出力
    • これが1回目のreducer実行のときの出力
  • → { age: 1 }
    • これが StrictMode による2回目のreducer実行のときの出力
    • 本番環境では、この分の実行がされないので、age の値が増えない

のような挙動になっているのが確認できると思います。

こうなる理由は説明が難しいので省略しますが、DevTools のコンソールを利用して、以下の文を1行ずつ実行してみると、someVar++ ++someVar の違いがわかると思います。

let hoge = 0
hoge++ // -> 0 と出力される
hoge // -> 1 と出力される

let fuga = 0
++fuga // -> 1 と出力される
fuga // -> 1 と出力される
Honey32Honey32

言い忘れていました、「破壊的/非破壊的」というキーワードを知っておくと、reducer や set〇〇関数で使って良い/悪いコードについて調べる役に立つかもしれません。

  • 破壊的
    • 「オブジェクト自身の状態を変更する」ような演算子、メソッドのこと
  • 非破壊的
    • 「オブジェクト自身の状態は変更せず、新しいオブジェクトを作る」ような演算子、メソッドのこと