🤔

React において props がイミュータブルである理由について改めて考えてみる

2024/08/28に公開

はじめに

React において、コンポーネントに渡される props はイミュータブル(=不変)であるため通常、値を書き換えることはしません。

しかし

「そもそもなぜ props は書き換えない方が良いのか?」

と疑問を持ったことはないでしょうか?

そこで今回は props がイミュータブルである理由について、 React の思想に触れながら少し掘り下げて考えてみたいと思います。

興味がある方は、ぜひ最後までご覧ください。

TL;DR

  • props をイミュータブルにするのはコンポーネントの純粋性を保つためである
  • イミュータブルに保たないと副作用や単方向データフローに問題が生じてしまい、予期せぬバグを引き起こしかねない(=純粋性が壊れる)
  • 純粋性とは以下の 3 つの特徴をもつこと
    • 冪等であること
    • レンダー時に副作用がないこと
    • ローカルな値以外を変更しないこと
  • コンポーネントを純粋に保つことで、Concurrent Mode や Transition、メモ化など快適なユーザー体験につながるさまざまなことを実現できるようになる

よくある例

まず以下のようなコンポーネントを考えてみます。

ユーザー名を出力する
type User = { firstName: string; lastName: string };

function UserCard(props: { user: User }) {
  return (
    <div className="user-card">
      <h2>
        {props.user.firstName} {props.user.lastName}
      </h2>
    </div>
  );
}

ここで表示させているユーザー名を小文字にしたいとなった場合、みなさんならどうするでしょうか?

もっとも一般的なのが下記のようにインラインで編集する方法でしょう。

インラインで小文字にしたものを埋め込む
function UserCard(props: { firstName: string; lastName: string }) {
  return (
    <div className="user-card">
      <h2>
        {props.firstName.toLowerCase()} {props.lastName.toLowerCase()}
      </h2>
    </div>
  );
}

あるいはローカル変数を新たに定義して書き換える方法も思いつくかもしれません。(レンダー中に作成した変数であれば、書き換えを行ってもまったく問題ない)

ローカル変数による書き換え
function UserCard(props: { firstName: string; lastName: string }) {
  const lowerFirstName = props.firstName.toLowerCase();
  const lowerLastName = props.lastName.toLowerCase();

  return (
    <div className="user-card">
      <h2>
        {lowerFirstName} {lowerLastName}
      </h2>
    </div>
  );
}

ただし、props を直接書き換えてしまう以下の場合はどうでしょうか?

props を直接書き換える
type User = { firstName: string; lastName: string };

function UserCard(props: { user: User }) {
  props.user.firstName = props.user.firstName.toLowerCase();
  props.user.lastName = props.user.lastName.toLowerCase();

  return (
    <div className="user-card">
      <h2>
        {props.user.firstName} {props.user.lastName}
      </h2>
    </div>
  );
}

たしかにユーザー名は正しく小文字になりますが、これには問題点があります。

副作用

結論から言うと、「コンポーネントの外側にあるデータを変更してしまっているため予期せぬデータの書き換えが起こってしまう」 ことが問題です。

たとえば、さきほどのコンポーネントで親コンポーネントが存在していた場合、

副作用が生じてしまう例
type User = { firstName: string; lastName: string };

function UserCard(props: { user: User }) {
  props.user.firstName = props.user.firstName.toLowerCase();
  props.user.lastName = props.user.lastName.toLowerCase();

  return (
    <div className="user-card">
      <h2>
        {props.user.firstName} {props.user.lastName}
      </h2>
    </div>
  );
}

function App() {
  const user = { firstName: "Bob", lastName: "Jones" };

  return (
    <div>
      <UserCard user={user} />
      <button onClick={() => alert(`Hello, ${user.firstName}!`)}>Greet</button>
    </div>
  );
}

ボタンをクリックすると、「Hello, Bob!」と表示されてほしいところですが、実際には「Hello, bob!」と表示されてしまいます。これは期待された動作とは違うはずです。

つまり、props は親コンポーネントにあるデータなので、変更すると親コンポーネント部分にも影響を及ぼしてしまうのです。

これと似た例として、公式ドキュメントの下記の例があります。

https://ja.react.dev/learn/keeping-components-pure#side-effects-unintended-consequences

外部に宣言された変数によって副作用が生じる例
let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

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

このコンポーネントは、外部で宣言された guest 変数を読み書きしています。つまり、このコンポーネントを複数回呼び出すと、異なる JSX が生成されます! さらに悪いことに、ほかのコンポーネントも guest を読み取る場合、それらもレンダーされたタイミングによって異なる JSX を生成することになります! これでは予測不可能です。

変更するのは props ではありませんが、外部に定義された変数を変更しているので実態としては同じです。


これらの例のように、呼び出し元に値を返すこと以外に(観察可能な)影響を及ぼすコード全般のことを「副作用」[1]と呼びます。

React ではレンダーとは「直接的に」関係のないものが副作用にあたり、

  • DOM の変更
  • API との通信
  • グローバル変数の書き換え

などが該当します。

公式ドキュメントを読む

ここで公式ドキュメントを読んでみると、「副作用を発生させないことはコンポーネントが純粋であるための条件のひとつ」と書いてあります。

https://ja.react.dev/learn/keeping-components-pure

https://ja.react.dev/reference/rules/components-and-hooks-must-be-pure

そしてこの純粋性を保つためには、コンポーネントには下記のようなことが必要であると述べられています。

  • 冪等 (idempotent)[2] であること
    • 同じ入力で実行するたびに常に同じ結果が得られること。
    • レンダー中に実行されるあらゆるコードは冪等でなくてはならない
    • コンポーネントの入力とは props と state とコンテクスト
  • レンダー時に副作用がない
    • 副作用 (side effect) を伴うコードはレンダーとは別に実行する必要がある。
    • たとえばユーザが UI を操作しそれによって UI が更新される場合はイベントハンドラーとして、またはレンダー直後に動作させる場合はエフェクトとして実行する。
  • ローカルな値以外を変更しない
    • コンポーネントとフックは、レンダー中にローカルに作成されたものではない値を決して変更してはならない。
    • とくに props と state はイミュータブルであり、書き換えてはならない

https://ja.react.dev/reference/rules/components-and-hooks-must-be-pure#why-does-purity-matter

コンポーネントを冪等にする

まず最初の「冪等」ですが、説明文にもある通り同じ入力であれば常に同じ結果が得られることを指します。

冪等の例でおそらく皆さんにもっとも馴染み深いのが数学における関数でしょう。

たとえば、y = 2x という関数があった場合

  • もし x = 2 ならば常に y = 4
  • もし x = 3 ならば常に y = 6

y は同じ入力を与えられると常に同じ結果になります。

React もこの冪等性に則って設計されており、コンポーネントは与えられた入力が同じであれば、常に同じ JSX を返す必要があります。

副作用はレンダー時に実行しない

「副作用をレンダー時に実行しない」というのは先ほど例に挙げたような処理をレンダー中に実行させないということを表しています。

副作用をレンダー時に実行しない理由としては、React が最適なユーザー体験のために複数回コンポーネントをレンダーする可能性があることに起因しています。

たとえば、React は更新がそれほど重要でないコンポーネントのレンダー処理を一時停止し、あとで必要になったときに再開するといった処理(Concurrent Mode や Transition など)を行うのですが、このような場合には複数回レンダーが走る可能性が考えられます。

しかし、このとき React が把握できない副作用、たとえばグローバル変数の書き換えのようなことを行っている場合、React がコードを再実行した際にその副作用が期待しない形でトリガーされることになり、バグを引き起こしかねません。

先ほどの例がわかりやすいですね。
<Cup />は実行されるたびに結果が変わってしまいます。

【再掲】外部に宣言された変数によって副作用が生じる例
let guest = 0;

function Cup() {
  // Bad: changing a preexisting variable!
  guest = guest + 1;
  return <h2>Tea cup for guest #{guest}</h2>;
}

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


では、レンダー中に実行してはいけない副作用は一体どこでなら実行しても問題ないのでしょうか?

結論から言うと、イベントハンドラーを使用して(たいていの場合)実行できます。

イベントハンドラを使用することで、そのコードはレンダー中に実行しなくてよいと React に明示的に伝えていることになり、レンダーは純粋に保たれます。

そしてイベントハンドラで無理なら最後の手段として、useEffect を使用して処理することもできます。

ローカルな値以外を変更しない

これは最初の例から派生させて考えるとわかりやすいかもしれません。

最初の例で下記のような実装をした場合どうなるでしょうか?

propsをオブジェクト経由ではなく直接渡す
function UserCard(props: { firstName: string; lastName: string }) {
  props.firstName = props.firstName.toLowerCase();
  props.lastName = props.lastName.toLowerCase();

  return (
    <div className="user-card">
      <h2>
        {props.firstName} {props.lastName}
      </h2>
    </div>
  );
}

function App() {
  const user = { firstName: "Bob", lastName: "Jones" };

  return (
    <div>
      <UserCard {...user} />
      <button onClick={() => alert(`Hello, ${user.firstName}!`)}>Greet</button>
    </div>
  );
}

たしかにこれだと副作用は発生せずにユーザー名を小文字に変えられているように思えますが、下記のようなエラーがでてしまいます。

TypeError: Cannot assign to read only property 'firstName' of object '#<Object>'

これは React の props オブジェクトが読み取り専用のプロパティを持っていることをいっています。

つまり、React 側から 「props の書き換えはするなよ」と警告をだしているのです。 これも要は props という親コンポーネントのデータを変更することは何らかの副作用を生じうるリスクを抱えているために禁止しています。

このように純粋性を重要視する React において props やその他レンダーに必要な入力値というの はイミュータブル(=不変)であることが前提としてあります。

React が純粋性にこだわる理由

最後に React が純粋性を重視する理由について少し考えてみましょう。

主な理由の 1 つは、みなさんにとって馴染み深いパフォーマンス面です。

例えば、入力値が変化しない場合、以前の結果をキャッシュすることで 2 回目以降のレンダリングをスキップすることが可能になります。いわゆるメモ化と呼ばれる技術です。このような最適化はコンポーネントが純関数であるからこそ実現可能です。

さらに、大規模な開発においては以下のような利点があります:

  • バグの減少
  • メンテナンス性の向上
  • テスト性の向上
  • 再利用性の高いコンポーネントの作成

つまり、快適な体験(開発者体験も含む)を提供するためには、純粋性が欠かせない要件となっており、そのために React は純粋性を重要視しているのです。

おわりに

ということで、React において props をイミュータブルに保つべき理由と、その背景にある純粋性についてざっくりと見てきました。

普段何となく使っていることにも、ちゃんとした理由や背景があるのはおもしろいですね。

最後まで読んでいただき、ありがとうございました!

参考文献

https://www.epicreact.dev/can-you-modify-react-props

脚注
  1. https://en.wikipedia.org/wiki/Side_effect_(computer_science) ↩︎

  2. https://en.wikipedia.org/wiki/Idempotence ↩︎

Discussion