備忘録: Reactコンポーネントの純粋性と副作用について理解する

はじめに

Reactではコンポーネントは純粋 (Pure) であることが求められる。純粋であるとは具体的にはどういうことか、またなぜ純粋性 (Purity) が求められるのか、そして副作用とはどう関係するのかを考察してみた。

コンポーネントを純粋に保つ – React
The library for web and native user interfaces

コンポーネントの純粋性とは

公式ドキュメントでは、純粋性を純粋関数を使って説明している。純粋関数は、次のような条件を満たす。

  • 同じ入力に対しては常に同じ出力を返す
  • 副作用が発生しない (副作用の具体例は後述する)

コンポーネントの実態は関数であり、純粋であるためには上記の条件を満たす必要がある。

純粋関数の入力は関数の引数であるが、コンポーネントの場合は次の3つが入力となる。

  • 引数
  • ステート
  • コンテクスト

これらが同じときに常に同じ出力 (JSX) を返すことで、コンポーネントが純粋であるとみなせる。

ステートやコンテクストは入力なのか?

ステートやコンテクストが入力であることは、公式ドキュメントにも記載されている。ただこれは純粋関数の定義からは少し違和感がある。「入力が同じなら」の入力の対象を広げてしまえば、およそどんな関数も純粋関数になり得るのでは? という疑問が湧いてくるからだ。まあステートとコンテクストに限定する、ということで違和感は受け入れることにした。

フックからの出力もコンポーネントへの入力になる

ステートやコンテクストの他に、フックの中には何らかの値を出力し、その値がコンポーネントの出力に使われるものがある。それらのフックからの出力も同様にコンポーネントへの入力と見なすことができる。そのようなフックは、出力を保持するために内部でステートを持っていると考えられる。

レンダリングサイクルと純粋性の関係

Reactにとって重要なのは、あるレンダリングサイクル内で、コンポーネントからは同じ出力 (JSX) が返ってくることである。これによりコンポーネントの呼び出し順が変わったり複数回呼び出されても、同じ結果が得られることになる (これはReactにとってレンダリングを簡素化し、最適化のための自由度を増すことにつながる (らしい))。

これはコンポーネントが純粋関数で、入力がそのレンダリングサイクル中に変更されなければ、満たすことができる。

入力となる引数、ステート、コンテクストの三つはReactが管理しており、レンダリングサイクルの途中で変更されないことが保証されている (レンダリングサイクルを途中で打ち切って、新しい入力値を元にレンダリングサイクルをやり直すことはあるらしい)。

ステートやコンテクストの更新とコンポーネントの呼び出し

下図はコンポーネントを呼び出すトリガーを模式的に示している。親コンポーネントが呼び出されると、レンダリング結果を元に全ての子コンポーネントが呼び出される。次はそのコンポーネントが親となり、子コンポーネントが呼び出される。このプロセスが末端のコンポーネントにたどり着くまで再帰的に実行される。

ステートやコンテクストの変更も、それらを参照しているコンポーネントの呼び出しをトリガーする。ただしそれが起きるのは次のレンダリングサイクルである。またその場合は、親コンポーネントの呼び出しはなく、参照しているコンポーネントが直接呼び出される。

副作用と画面出力

副作用とは

関数の外部に何らかの作用を及ぼすことで、例えば次のような処理が該当する。

  • ユーザーの入力を受けてバックエンドのサーバーに送る
  • バックエンドのDBから初期データを読み出す

アプリとしてはこうした外部とのやり取りは一般的であり、アプリを構成するコンポーネントも、必要に応じてこうしたやり取り (副作用) を担うことになる。

副作用とレンダーコードの分離

その発生トリガーからみて、副作用には大きく2種類がある。

  • マウスやキーボードなどからのイベントに起因するもの
    例: ユーザーの入力を受けてバックエンドのサーバーに送る
  • イベントに起因しないもの
    例: バックエンドのDBから初期データを読み出して表示する

イベントに起因するものはイベントハンドラを定義して処理する。問題はイベントに起因しないものである。そのような処理は他にトリガーが無いので、レンダーコード(※)の中で実行する必要がある。しかしそのままでは次のような問題が発生する可能性がある。

  • コンポーネントが呼び出される度に不必要なコードが実行される (例: 初期値の読み込みは初回のみで良いのに毎回読み込まれる)
  • 時間のかかる処理の場合、画面の更新 (レンダーコードの終了後に実行される) までに時間がかかり表示が遅くなる

※ レンダーコードはコンポーネントの本体であり、コンポーネントが呼び出されると実行され、出力としてJSXを返すコードのこと。(公式ドキュメントの呼称に従った)

useEffectフックによる解決

このような問題を解決するために提供されているのがuseEffectフックである。useEffectフックを使うと、副作用となる処理をコールバックとして登録し、レンダーコードの外で実行条件を指定して実行できる。これにより不要な処理の実行を避けられる。またコールバックは画面描画が終わった後に呼び出されるため、画面の表示を遅延させることもない。 

備忘録: React useEffectの実行条件と実行タイミング
ReactのHookの中でも使用頻度の高いuseEffect。レンダリング以外の処理を行うときに重宝しているが、その実行条件や実行タイミングについて理解していないと、思わぬ落とし穴にハマることになる。そんな経験を踏まえて筆者なりの理解をまとめてみた。

副作用による画面の更新

副作用を処理した結果、画面を更新したい場合がある。イベントハンドラもuseEffectのコールバックも、レンダーコードとは分離されているので、コンポーネントから出力されるJSXの生成には直接関与できない。代わりにDOMを直接操作して画面を更新することは可能だが、これは後述するように推奨されていない。そこで画面を更新するには、副作用の結果をステートに格納してコンポーネントの入力とすることで、次のレンダリングサイクルを起動してコンポーネントの出力に反映する。

これによりレンダーコードの純粋性は保ったまま、副作用による画面の更新が可能になる。

DOMを直接更新してはいけない理由

DOMを直接更新すると、通常の画面の更新との関係でフリッカーを生じる場合がある。またコンポーネントの出力するJSXと実際のDOMの内容が合わなくなる。これにより問題判別の難しいバグを誘発したり、DOMの更新が意図せず上書きされてしまう可能性がある。

DOMの直接更新が必要な例外

ただし次のような場合は、DOMを直接操作する必要がある。

  • ノードにフォーカスを当てる
  • スクロールさせる

これらはReactに組み込みの方法がないため、DOMを直接操作するしかない。

なぜ純粋性が求められるのか?

設計や実装の簡素化と品質の向上

Reactを使ったアプリケーションは、大抵の場合ユーザーやバックエンドのシステムとデータのやり取りをし、その結果を元にUIを構築するという形をとるだろう。(全く静的なコンテンツの表示にReactを使うことも可能ではあるがあまり意味はなさそう …)

Reactのアーキテクチャに従えば、ユーザーやバックエンドのシステムとのデータのやり取り (つまり副作用) と、UIの構築は自ずと分離されることになる。いわゆる関心の分離 (Separation of Concerns) が適用される。これは設計や実装の簡素化につながり、メンテナンスのし易さやバグの回避といった品質の向上にもつながる。

これは純粋性そのもののメリットではないが、Reactのアーキテクチャがコンポーネントの純粋性を前提にしており、その結果得られるメリットと考えることができる。

パフォーマンスの改善

同じ入力に対して同じ出力を得るということは、入力が同じであればコンポーネントの再描画は必要ないことを意味する。この特性を活かしたパフォーマンス改善の方法としてメモ化がある。

テストの容易性

入力に対する出力が常に同じであることから、テストケースの切り分けが容易になる。特に引数やコンテクストのみを入力とするコンポーネントの場合は、入力値のバリエーションを考えればよく、タイミング依存の振る舞いを気にする必要がない。

ステートを入力に含むコンポーネントの場合は、ステートを外部から変更することができない。ステートは通常副作用の処理の中で変更されるので、副作用自体のテストと組み合わせてテストする必要がある。このためにスタブの用意など工夫が必要となる。

テストについては、Jestなどテストフレームワークの利用を前提に評価すべきだが、筆者にはそこまでの知見はない。「副作用を持つコンポーネントのテストはどうやるのか」という興味はあるので何れ調べてみたい。

その他のメリット

公式ドキュメントには、他にも次のような項目が挙げられている。

  • コンポーネントが異なる環境、例えばサーバ上でも実行できるようになります! 入力値が同じなら同じ結果を返すので、ひとつのコンポーネントが多数のユーザリクエストを処理できます。
  • 深いコンポーネントツリーのレンダーの途中でデータが変化した場合、React は既に古くなったレンダー処理を最後まで終わらせるような無駄を省き、新しいレンダーを開始できます。純粋性のおかげで、いつ計算を中断しても問題ありません。

変更履歴

日付内容
2024/06/02純粋性についてのコラムを補筆
2024/05/11初版リリース