💬

observableな値で無限ループしないために

に公開

この記事を書こうと思ったきっかけ

Team DELTA の三浦です。
フロントエンドエンジニアとして、ShopifyやNext.jsで開発をしています。

今回はReact/MobXで開発していて無限レンダリングループを引き起こしてしまったため、その解決策を書きました。

どんな人に読んでほしいか

Reactのライフサイクル、雰囲気でやっている。という方
MobXで開発していて、observableとcomputedの使い分けが分からん。という方
(他の状態管理ライブラリをお使いの方には参考になるか分からず)

今回の問題

無限レンダリングループが起きたのは登録フォームのページです。
登場人物は4つ。
RegisterPage:登録フォームを表示するページ用コンポーネント。
InputLayout:入力欄が数個集まったフォームコンポーネント。
useValid:フォームのリアルタイムバリデーション・送信処理を行うフック。
InputStore:フォームの状態を管理するストア。(MobX)

フォームに入力するとuseValidによってリアルタイムバリデーションが走り、その結果はInputLayoutに設定された関数によりInputStoreへ格納されます。

特定の条件によってエラーメッセージを出し分けたかったので、条件判定のためにobservableで設定していたinputStore.status.preValidateを参照するようにしました。

{inputStore.status.transactionStatus?.error &&
    inputStore.status?.preValidate?.number.empty &&
    inputStore.status?.preValidate?.birthday.empty &&
    inputStore.status?.preValidate?.address.empty &&
    inputStore.status?.preValidate?.name.empty && (
    <div className={errorStyle}>{store.status.transactionStatus.error}</div>
)}
{inputStore.status.transactionStatus?.error &&
    (inputStore.status?.preValidate?.number.empty ||
    inputStore.status?.preValidate?.birthday.empty ||
    inputStore.status?.preValidate?.address.empty ||
    inputStore.status?.preValidate?.name.empty) && (
    <div className={errorStyle}>情報を全て埋めるか、空欄にしてください。</div>
)}

するとこのエラーメッセージを追加した直後から、入力欄に何かを入れると無限レンダリングループが始まり、Chromeから応答がなくなって落ちてしまいました。

無限レンダリングループの仕組み

無限レンダリングループが発生したと思われる順番をもう少し詳しく書いてみます。
最初は「ただ条件分岐しただけなのに!?」と思っていましたが、MobXの仕組みを理解すると、原因が見えてきそうです。

MobXの反応性の仕組み

MobXはReactiveな状態管理ライブラリで、observableなプロパティにアクセスすると、そのプロパティが「観測される」ようになります。

参考:MobXの反応性について

具体的には、以下のような流れでループが発生したと思われます:

  1. 初期レンダリング
    RegisterPageコンポーネントが初期レンダリングされ、InputLayoutコンポーネントもレンダリングされます。

  2. コールバックの設定
    InputLayoutに以下のようなコールバックを渡しています。

onPreValidateChange={v => inputStore.status.setPreValidate(v)}
  1. 状態の更新
    InputLayoutコンポーネント内部で、入力フィールドの状態が変わると、このコールバックが呼び出され、inputStore.status.setPreValidate(v)が実行されます。

  2. MobXによる再レンダリング
    これによりMobXのobservable状態が更新され、RegisterPageコンポーネントの再レンダリングがトリガーされます。
    参考:MobXとReactの統合について

  3. 条件式の評価
    再レンダリング時に、問題のコードが実行されます。

{inputStore.status.transactionStatus?.error &&
  (inputStore.status?.preValidate?.number.empty ||
    inputStore.status?.preValidate?.birthday.empty ||
    inputStore.status?.preValidate?.address.empty ||
    inputStore.status?.preValidate?.name.empty) && (
    <div className={errorStyle}>情報を全て埋めるか、空欄にしてください。</div>
  )}
  1. observableプロパティへのアクセス
    この条件式の評価中に、inputStore.status?.preValidateの各プロパティにアクセスすることで、MobXの反応性システムがこれらのプロパティを「観測」します。

  2. 副作用の発生
    この観測が副作用を引き起こし、再びInputLayoutのonPreValidateChangeが呼び出されます。

  3. 無限ループの形成
    これにより再びRegisterPageが再レンダリングされ、同じプロセスが繰り返されて無限ループになります。

副作用って何?

「副作用」と聞くと難しく感じるかもしれませんが、簡単に言うと「予期せぬ動作」のことです。この場合、具体的には以下のような副作用が考えられます:

  1. コンピューテッド値の再計算:
    inputStore.status?.preValidateの各プロパティにアクセスすると、これらのプロパティに依存するコンピューテッド値が再計算されます。
    MobXのコンピューテッド値について

  2. 自動実行されるリアクション:
    MobXでは、autorunreactionなどの機能を使って、特定のobservableプロパティが変更されたときに自動的に実行されるコードを定義できます。
    MobXのリアクションについて

  3. コンポーネントの内部状態更新:
    InputLayoutコンポーネント内部で、preValidateの値が変わると内部状態を更新するロジックがあるかもしれません。

  4. コールバックの再呼び出し:
    最も可能性が高いのは、preValidateの値へのアクセスが、何らかの形でonPreValidateChangeコールバックを再度トリガーしていることです。

解決策

observableな値:preValidateの各値を条件判定に使うのをやめ、送信ボタンを押したときにだけ、isPartiallyFilledという値をセットして判定に使うようにしました。

{inputStore.status.transactionStatus?.error &&
    !inputStore.status.isPartiallyFilled && (
    <div className={errorStyle}>{inputStore.status.transactionStatus.error}</div>
)}
{inputStore.status.transactionStatus?.error &&
    inputStore.status.isPartiallyFilled && (
    <div className={errorStyle}>カード情報を全て入力するか、空欄にしてください。</div>
)}

なぜこの解決策が効果的なのか?

この解決策が効果的な理由は、複雑な条件チェックをコンポーネントのレンダリングロジックから分離し、ストア内のコンピューテッド値(isPartiallyFilled)を使用しているからです。

InputStoreの実装を見てみましょう:

// InputStore.tsから
get partiallyFilled() {
  if (!this.preValidate) {
    return false;
  }
  return this.allFieldsEmpty === false && this.preCardInputCompleted === false;
}

public isPartiallyFilled = false;

public setIsPartiallyFilled() {
  this.isPartiallyFilled = this.partiallyFilled;
}

この方法では、レンダリング中に複数のobservableプロパティに直接アクセスする代わりに、あらかじめ計算された値を使用しています。これにより、レンダリング中の予期せぬ副作用を防ぎ、無限ループを回避できます。

MobXでは、このようにcomputedプロパティを使うことで、パフォーマンスと予測可能性を向上させることができます:
MobXのobservableとcomputedの使い分け

他の解決策

他にも以下のような解決策が考えられます:

  1. computed値を使用する: 直接複数のobservableプロパティにアクセスする代わりに、InputStoreに計算済みのプロパティを追加します。例えば:
// InputStore.tsに追加
get hasEmptyFields() {
  if (!this.preValidate) return false;

  return (
    this.preValidate.number.empty ||
    this.preValidate.birthday.empty ||
    this.preValidate.address.empty ||
    this.preValidate.name.empty
  );
}

そして、JSXでは:

{inputStore.status.transactionStatus?.error &&
  inputStore.status.hasEmptyFields && (
  <div className={errorStyle}>情報を全て埋めるか、空欄にしてください。</div>
)}
  1. useEffectの依存配列を適切に設定する: カスタムフックでは、useEffectの依存配列を適切に設定することも重要です。空の依存配列([])を使用すると、コンポーネントのマウント時にのみ実行されますが、内部でストアの値を参照している場合は、それらの値を依存配列に含める必要があります。
    Reactの公式ドキュメント - useEffectの依存配列

まとめ

無限レンダリングループが起きた順番は以下の通りです:

  1. フォームに入力が行われる
  2. InputLayoutのonPreValidateChangeコールバックが呼び出される
  3. inputStore.status.setPreValidate(v)が実行される
  4. RegisterPageが再レンダリングされる
  5. 条件式でinputStore.status?.preValidateの各プロパティにアクセスする
  6. このアクセスが副作用を引き起こし、再びonPreValidateChangeが呼び出される
  7. 3〜6が無限に繰り返される

リアクティブなデータにアクセスできることこそがMobXの設計思想ではありますが、

  • どんな頻度で更新される値なのか?
  • 再レンダリングのトリガーになりうるものは?
  • レンダリング中に複数のobservableプロパティにアクセスしていないか?
  • コンピューテッド値を活用できる場所はないか?

という視点があるといいかもしれません。

参考リンク

DELTAテックブログ

Discussion