Reactコンポーネントが「純粋である」とはどういうことか? 丁寧な解説
Reactにおいては、コンポーネントは純粋であるべきだとされています。これはReactのルールの一部であり、ルールを守らずに非純粋なコンポーネントを作った場合、さまざまな不都合が発生する恐れがあります。
- 書いた通りの挙動にならない(ように見える)
- 今はうまく動いていたとしても、関係ない箇所を修正すると急に壊れる
- 最適化(React Compiler)によって挙動が変わり、バグの原因となる
- Reactの新機能と互換性が無くなり、新機能の恩恵を受けられなくなる
- Reactのアップデートで壊れてしまったり、破壊的変更の影響を受けたりする
- 単純にコードの可読性が低下し、保守性が悪化する
では、コンポーネントが “純粋” であるとは、具体的にどういうことを指すのでしょうか。Reactを使いこなしている人は経験的にこのことを理解していますが、正確に説明しろと言われると困る人も多いのではないでしょうか。
そこで、この記事では、Reactにおけるコンポーネントの純粋性についてなるべく丁寧に解説し、Reactにおける純粋性に関する様々な疑問を解決することを目指します。
最初に公式ドキュメントを見る
Reactの公式ドキュメントでも、コンポーネントは純粋であるべきだと説明されています。そのため、純粋とはどういうことかについても説明があります。
このコンポーネントでは具体例や平易な言葉を使ってわかりやすく純粋性について説明することが試みられており、とくに核心に迫る点を以下に引用します。
コンピュータサイエンス(特に関数型プログラミングの世界)では、純関数 (pure function) とは、以下のような特徴を持つ関数のことを指します。
- 自分の仕事に集中する。 呼び出される前に存在していたオブジェクトや変数を変更しない。
- 同じ入力には同じ出力。 同じ入力を与えると、純関数は常に同じ結果を返す。
React はこのような概念に基づいて設計されています。React は、あなたが書くすべてのコンポーネントが純関数であると仮定しています。 つまり、あなたが書く React コンポーネントは、与えられた入力が同じであれば、常に同じ JSX を返す必要があります。
React のレンダープロセスは常に純粋である必要があります。コンポーネントは JSX を返すだけであり、レンダー前に存在していたオブジェクトや変数を書き換えしないようにしなければなりません。さもなくばコンポーネントは不純 (impure) になってしまいます!
ここに書いてあることはもちろん間違いではありませんが、より多くの人が理解するためには、さらに行間を埋めてあげる必要があると考えています。
この記事では、公式ドキュメントを読んだけど腑に落ちない人や、特に読んでいないけど何となく経験的にReactにおける純粋性を理解している人から、そもそも純粋性がよく分からない人など幅広い読者を対象に、さらに丁寧な解説を提供することを目指します。ただし、Reactの基本的な使い方については前提知識としていますので、ご了承ください。
純粋性ってなに?
考察の第一歩として、純粋という言葉の意味をちゃんと考えましょう。
「純粋性」とは、本来関数が持つ性質です。「純粋な関数」とか、「純粋ではない関数」といった言い方をします。Reactにおいては、コンポーネントは関数であるため(クラスコンポーネントはここでは忘れましょう[1])、コンポーネントが純粋であるとは、おおよそ、そのコンポーネントを関数として見たときに純粋であることを指すでしょう。
では、純粋関数とは何でしょうか。これについてはReact特有の概念ではなく、どちらかというと関数型プログラミングのコミュニティのほうが知見が豊富でしょう。普通は、純粋関数とは、以下の2つの条件を満たす関数を指します。
- 副作用を含まない。つまり、関数を実行した結果として、関数の外部に対して影響を与えないこと。
- 参照透過性を持つ[2]。つまり、同じ入力を与えたときに、常に同じ結果を返すこと。
ただし、定義にもいろいろ流派があるようです。たとえば、「関数の外部から何らかの情報を読み取ること」を副作用に含むことにすれば、参照透過性は省略して「副作用を含まない」という条件だけで純粋関数を定義することもできるでしょう。
例: この関数は副作用を含むか?
function randInt(max: number): number {
return Math.floor(Math.random() * max);
}
この関数は、明らかに参照透過性を持ちません。なぜなら、同じ引数を与えても、毎回異なる結果を返すからです。しかし、この関数が副作用を持つかどうかは、意見が分かれるところです。以下のような意見が考えられるでしょう。
- 関数の外部に何かを書き込んでいないので、副作用は持たない。
-
Math.random()
は関数外部から情報を関数内に取り込んでいるので、副作用を持つ。 - そもそも
Math.random()
はその裏にいる疑似乱数生成器の状態を変更するので、副作用を持つのでは?
ただし、この記事では3のような考え方はしないことにします。Math.random()
の呼び出しが裏で何らかの書き込みを伴うかどうかは、JavaScriptの言語仕様による抽象化の向こう側の世界の話であり、JavaScriptプログラムの純粋性の話をするときに考えなくてもいい領域だからです。
1と2の違いは、そもそも「副作用」の定義が人によって違うところから来ています。
Math.random()
の他にも、new Date()
なども同じような議論ができるでしょう。
ここで、筆者が最近見かけた純粋性・副作用についての議論で、納得感があった考え方を紹介します。筆者の言葉で言い換えると、あらゆる場面で通用する汎用的な「純粋性」の共通の定義を考えることにはあまり意味がありません。そうではなく、具体的な場面で 「その関数が純粋だとなぜ嬉しいのか」、言い換えると純粋関数であることのメリットを考え、それに沿って純粋性を定義するのが良いだろうということです。
要するに、今回はReactコンポーネントの純粋性について考えているのですから、「Reactの文脈での純粋性の定義」 のようなものを考えてもいいということです。もちろん限度はあります。React以外の分野でも使われている言葉を借りるのですから、あまり好き勝手にはできず、純粋性の共通認識をある程度尊重する必要はあるでしょう。
そうなると、Reactコンポーネントが純粋でなければならない理由とは、その前提で実現されるReactの機能や挙動があるからです。具体的には、SuspenseやConcurrent Rendering、高度なジョブスケジューリング、そしてReact Compilerによる最適化などです。つまり、Reactの文脈での純粋性の定義は、これらの機能を実現するために必要な条件を満たすものであれば良いということです。
とはいえ、結局のところ、Reactのコンポーネントが純粋であるかどうかについては、基本的には上記のように「副作用を持たない」「参照透過性を持つ」という条件で考えて構いません。ただし、細かく考えていくと定義に調整が必要になってしまうので、それはのちのち説明していきます。ここで言いたかったことは、そのように「純粋性」の定義を調整することは別におかしいことではなく、むしろReactの文脈で「純粋性」の概念を効果的に運用するために必要なことだということです。
副作用の例
Reactコンポーネントが純粋でなければならないということは、すなわち副作用があってはいけないということです。ここで、Reactコンポーネントにおける副作用とは何なのかイメージを持つために、副作用を持つ(すなわち、だめな)コンポーネントの例をいくつか挙げてみましょう。
なお、ここからは外部から何かを読み取る(参照透過ではない)ことも副作用に含めて説明していきます。これはReact界隈ではこれも「副作用」に含めて説明されがちなことと、「副作用」と「参照透過性」の2つの用語を使い分けて説明するのが大変なことが理由です。
1. コンポーネントの外部に何かを書き込む
let renderCount = 0;
const Counter: React.FC = () => {
renderCount++;
return <div>{renderCount}</div>;
};
この例は、かなり単純な副作用の例になっています。関数コンポーネントの処理内で、コンポーネントの外部にある変数renderCount
をインクリメントしています。これが外部への書き込みです。
Reactの文脈でこれをやってはいけない理由は、このCounter
という関数がいつ何回実行されるのかは、Reactの仕様として保証されていないからです。「<Counter />
の中身が1回表示される=Counter
が関数として1回実行される」といった単純な関係はReactでは成り立ちません。
最も単純にrender(<Counter />);
みたいに実行した場合は、さすがに1回な気がしますが、それも将来にわたって保証されるわけではありません。より複雑な次のような場合は、<OtherComponent />
の挙動次第でCounter
が複数回実行されるかもしれません。
<Suspense>
<Counter />
<OtherComponent />
</Suspense>
このような場合、Counter
が関数として何回実行されるかは、Counter
の外部の要因によって左右されることになります。コンポーネントとしてのCounter
の仕様が外部要因に依存してしまっているのは、プログラムとして問題があるし、コンポーネントとして役に立ちません。
2. コンポーネントの外部から何かを読み取る
const UserInfo: React.FC = () => {
const userId = localStorage.getItem("userId");
return <p>ユーザーID: {userId}</p>;
};
この例では、コンポーネントの外部(今回はlocalStorage)から何かを読み取っています。これも、コンポーネントの外部の状態に依存しているため、コンポーネントとしては不純です。
前述のように、ReactはUserInfo
コンポーネントを表示するために、関数としてUserInfo
を何回も実行する可能性があります。そうなると、タイミング次第でUserInfo
が表示する内容が変わってしまいます。こうなると、コンポーネントとしての仕様が曖昧になってしまうので良くありません。
3. 同じ入力でも結果が変わる
const Lottery: React.FC = () => {
if (Math.random() < 0.1) {
return <p>当たり!</p>;
} else {
return <p>外れ</p>;
}
};
2の亜種ですが、これも結局何回も実行したら結果が変わる恐れがあるので、純粋なコンポーネントとは言えません。
Reactは宣言的プログラミングのためのライブラリであり、コンポーネントの仕様を宣言的に記述することが求められます。入力以外の要因で結果が変わるようなコンポーネントは宣言的プログラミングの利点を破壊してしまうため、避ける必要があります。
4. ログを出力する
const Logger: React.FC = () => {
console.log("Logger");
return <p>いい感じの表示内容</p>;
};
このようにconsole.log
でログを出力するのも副作用にあたります。とはいえ、フロントエンドの世界でこれをやるのはデバッグ中くらいでしょうから、デバッグ時にこれをやるなとは言いません。Reactではコンポーネントが関数としていつ何回実行されるかは保証されていないということを理解した上で、現状を把握するために使う分には問題ないでしょう。もちろん、デバッグが終わったら消すべきです。
5. ネットワークリクエストを行う
const MyPage: React.FC<{ user: User }> = ({ user }) => {
// マイページ表示時にページビューをサーバーに記録
fetch('/api/log/pageview', {
method: 'POST',
body: JSON.stringify({ page: 'mypage', userId: user.id }),
});
return <section>
<h1>マイページ</h1>
...
</section>;
};
このようにネットワークリクエストを行うのも副作用にあたります。関数の外部に目に見える形で影響を与えていますからね。
「マイページを表示したときにページビューをサーバーに記録したい」と思った場合、そのタイミングを「MyPage
関数が実行されたとき」と同一視してしまうと、1回ページを表示しただけなのに複数回リクエストが飛んでしまうといった現象に悩まされることになるでしょう。
“同じ”とはどういうことか
ここから、Reactにおける純粋性の定義について、一段階掘り下げて考えていきます。
特に、先ほど見たような「外部から読み取る」系の副作用を持つコンポーネントは、同じ入力を与えたのに異なる結果を返してしまうことがあります。
裏を返せば、純粋なコンポーネントであるためには、同じ入力を与えたときに、常に “同じ” 結果を返す必要があるのです。
ここで考えたいのは、この「同じ」というのは、具体的にどういうことなのかということです。
JavaScriptプログラムにおいて、同じであることを判定する基本的な方法は===
演算子を使うことです。
/** 10倍にする関数 */
function times10(x: number): number {
return x * 10;
}
const a = times10(1);
const b = times10(1);
// 同じだ!
console.log(a === b); // true
しかし、Reactコンポーネントにおいては、===
を使う考え方は通用しません。なぜなら、返り値(普通はJSX式の値)はオブジェクトだからです。
const Hello: React.FC = () => {
return <h1>Hello</h1>;
};
// 普通は関数コンポーネントをこのように直接呼び出しませんが、
// 今回は説明のために直接呼び出しています
const a = Hello();
const b = Hello();
// ===で比較しても同じにならない!
console.log(a === b); // false
この例で実際にa
やb
を確認してみると、このようなオブジェクトになっています(React 19の場合)。
{
'$$typeof': Symbol(react.transitional.element),
type: 'h1',
key: null,
props: { children: 'Hello' },
_owner: null,
_store: {}
}
このように、Reactのコンポーネントはオブジェクトを返すため、===
で比較しても同じになりません。しかし、さすがにこの例のHello
は純粋なコンポーネントのはずです。そのため、Reactの文脈では===
ではない別の基準で「同じ」を判断することになります。
結論から言えば、Reactの文脈では、同じというのは大まかに「意味が同じ」という意味で考える必要があります。とはいえ、「意味が同じ」というのは抽象的すぎますので、なるべく理解の助けになるようにさらに説明します。
JSX式はReactランタイムへの指示書である
典型的なReactコンポーネントは、このHelloコンポーネント(再掲)のように、JSX式を返します。
const Hello: React.FC = () => {
return <h1>Hello</h1>;
};
そして、<h1>Hello</h1>
が具体的にどんな値になるのかというと、先ほど見たようなオブジェクトになります。これは我々が普段目にすることはあまりありませんが、Reactのランタイムはこのオブジェクトを受け取って動いています。JSX式の実態がこのようなオブジェクトであり、JSXという構文はそれをHTMLに類似した形で直観的に表現するための糖衣構文であるということは、ぜひ理解しておきましょう。
このようなJSX式は、Reactのランタイムに対する指示書のようなものだと考えられます。つまり、Helloコンポーネントはこの返り値を通じて、「Helloコンポーネントをレンダリングするには、h1要素を作って、その中に"Hello"という文字列を入れなさい」という指示を出しているのです。こう考えると、“同じ”指示を出せばReactランタイムが同じ結果にしてくれることが期待できます。
つまり、Reactの文脈では、同じ指示を表すJSX式を毎回返すのであれば、コンポーネントは純粋であると考えることができます。
なお、現段階では、JSX式の同一性判定は「ASTとして同じ」という考え方も可能ではあります。この例の場合、<h1>Hello</h1>
というJSX式は「h1という要素であり、その中に子要素としてHelloという文字列がある」という木を表すデータ構造になっていますから、木としての同一性を考えることもできるでしょう。抽象的な話より実装がどうなっているかの話のほうが分かりやすいという方は、基本的なメンタルモデルとして採用してもよいでしょう[3]。
副作用を書いていい場所、書いてはいけない場所
これまでの説明の通り、コンポーネントが純粋であるためには、副作用があってはいけません。しかし、実際にはReactアプリケーションには副作用が必要な場面もあります。そのような副作用を書く場所について、公式ドキュメントに立ち返ると、以下の記載があります。
React では、副作用は通常、イベントハンドラの中に属します。イベントハンドラは、ボタンがクリックされたといった何らかのアクションが実行されたときに React が実行する関数です。イベントハンドラは、コンポーネントの「内側」で定義されているものではありますが、レンダーの「最中」に実行されるわけではありません! つまり、イベントハンドラは純粋である必要はありません。
筆者の経験上、Reactにおける純粋性について理解しようとする際、ここの理解で躓く方が多いようです。そのため、この記事でも丁寧に解説します。
例えば、イベントハンドラが副作用を持つ例とは、以下のようなものです。
const LoginButton: React.FC = () => {
return (
<button type="button" onClick={
() => {
// ログイン処理
fetch("/api/login", {
method: "POST",
body: JSON.stringify({ username: "user", password: "pass" }),
}).then((response) => {
// 省略
}).catch((error) => {
// 省略
});
}
}>
ログイン
</button>
);
};
この例では、ログインボタンを押すとネットワークリクエストという副作用が発生します。これは、ボタンのclickイベントに対するイベントハンドラの中で行われています。つまり、公式ドキュメントの教えのとおり、イベントハンドラの中で副作用を実行しています。
重要な点として、この例のLoginButtonコンポーネントは、Reactのルール上全く問題ない純粋なコンポーネントであるということです。
字面だけ見るとコンポーネントの返り値(JSX式)の中にがっつりfetchとか書いてありますが、それでもこれは、副作用のあるコンポーネントとは見なされません。一見すると、Reactコンポーネントの中に、副作用を書いていい場所と書いてはいけない場所があるように見えてややこしいですね。
一言で理屈を説明するなら、コンポーネントの純粋性は「返り値の計算に副作用が含まれてはいけない」ことだと説明できます。つまり、ボタンのイベントハンドラの中は返り値の計算ではないので、副作用があってもいいということです。
別の言い方として、先ほどの「指示書」の考え方を使ってみましょう。この場合、LoginButtonコンポーネントが出す指示とは、「ボタンを表示する。そのボタンをクリックしたらこの関数を実行する」というものです。 この関数が毎回同じものであるという前提なら、何回LoginButtonコンポーネントを実行しても、得られる指示は実質的に毎回同じになることが分かります。
説明のために、少しLoginButtonコンポーネントを書き換えて、イベントハンドラを変数に入れてみましょう。
const LoginButton: React.FC = () => {
const handleClick = () => {
// ログイン処理
fetch("/api/login", {
method: "POST",
body: JSON.stringify({ username: "user", password: "pass" }),
}).then((response) => {
// 省略
}).catch((error) => {
// 省略
});
};
return (
<button type="button" onClick={handleClick}>
ログイン
</button>
);
};
JavaScriptでは関数は関数オブジェクトであり、関数式(() => { ... }
)は新しい関数オブジェクトを生成します。つまり、上記のLoginButtonコンポーネントは、(返されるJSX式の結果がオブジェクトとしては毎回===
ではないのと同様に)ボタンのイベントハンドラ(handleClick)として毎回異なる関数オブジェクトを返すことになります。
LoginButtonコンポーネントが2回実行されたとき、1回目の実行結果に含まれるhandleClickと2回目の実行結果に含まれるhandleClickは、===
で比較すると同じではありません。しかし、どちらも実行したときの挙動はまったく同じですから、やはり「意味が同じ」なのです[4]。
この「意味が同じ」であることを根拠に、LoginButtonコンポーネントは純粋なコンポーネントであると扱われます。具体的に言うなら、2つのhandleClickをhandleClick1, handleClick2 と呼ぶことにすると、LoginButtonコンポーネントが返した1回目の指示は「ボタンを表示する。そのボタンがクリックされた場合はhandleClick1を実行する」であり、2回目の指示は「ボタンを表示する。そのボタンがクリックされた場合はhandleClick2を実行する」というものになります。ここで、handleClick1とhandleClick2は意味が同じ(=挙動が同じ)なので、結果的にLoginButtonが返した2回の指示は意味が同じとなります。そのため、LoginButtonコンポーネントは純粋なコンポーネントであると考えられるのです。
以上のように、「副作用を書いていい場所と書いてはいけない場所がある」というのがややこしければ、「指示書の意味」に注目するのが効果的です。コンポーネントの返り値として得られる指示書(JSX式の値)が、同じ入力に対しては同じ意味の指示書が返るのであれば、コンポーネントは純粋であると考えることができます。「意味が同じ」というのがいまいち抽象的な概念にはなってしまいますが、Reactコンポーネントが純粋かどうかを人間が判断する助けにはなるでしょう。
余談: 本当に“同じ”にするのは最適化のため
ReactはuseMemoやuseCallbackを使った経験がある方も多いでしょう。これらは、Reactコンポーネントにおいて扱われるデータを、本当に(===
の意味で)同じにするために使われます。
例えば、LoginButtonコンポーネントをちょっと変えてonLogin
をpropsとして受け取るようにしてみましょう。さらに、React.memo
でラップして、LoginButtonコンポーネントが同じpropsを受け取ったときに再レンダリングしないようにします。
const LoginButton: React.FC<{ onLogin: () => void }> = React.memo(({ onLogin }) => {
return (
<button type="button" onClick={onLogin}>
ログイン
</button>
);
});
ちなみに、このように実装したLoginButtonコンポーネントはもちろん純粋なコンポーネントです。なぜなら、受け取ったonLogin
の意味が同じであれば、LoginButtonから得られる指示書も(意味が同じという意味で)同じになるからです。
では、LoginButtonを使う側のコンポーネントを考えてみましょう。
const App: React.FC = () => {
const handleLogin = () => {
// ログイン処理
};
return <LoginButton onLogin={handleLogin} />;
};
このままでもAppは純粋コンポーネントですが、実際にはReact.memo
を活かすために、useCallbackを使ってこのように実装されるでしょう。
const App: React.FC = () => {
const handleLogin = useCallback(() => {
// ログイン処理
}, []);
return <LoginButton onLogin={handleLogin} />;
};
このようにuseCallbackを使うと、Appが複数回レンダリングされる場合、handleLoginは真の意味で“同じ”関数オブジェクトになります(つまり、===
で比較しても同じになります)。
ここでuseCallbackをわざわざ使う理由は、ReactランタイムがLoginButtonコンポーネントを再レンダリングする必要があるかどうか判断する際の挙動にあります。理想的には、onLogin
として渡されてきた関数が「同じ意味」のものであれば、LoginButtonコンポーネントは再レンダリングする必要がありません。しかし、プログラム上で2つの関数が「同じ意味」かどうか判定することはできません。そのため、ある種の次善策として、===
で比較して「同じ」かどうかを判定するのです。
逆に言えば、複数の関数が「同じ」かどうかをReactランタイムが判断するのを助けるために、「意味が同じ」関数をちゃんと===
で同じにしてあげるのが、useCallbackの役割です。Reactのルール的には関数が「意味が同じ」であれば問題ありませんので、useCallbackを使うのはReactのルールを守るためとかではなく、あくまでパフォーマンス最適化のためです。
ちなみに、「理想的には」と書きましたが、実はその理想を実現してくれるものがあります。それがReact Compilerです。React Compilerは、良く知らない方向けに言えば「ビルド時の処理として、useCallbackなどをいい感じに自動的に差し込んでくれるもの」です[5]。ランタイムに2つの関数が「意味が同じ」か判定することはできませんが、ビルド時に判定することはある程度可能です。そのため、React Compilerを使うと、我々がuseCallbackなどを使わなくても「意味が同じ」関数やオブジェクトが実際に===
で同じになるように、React Compilerが自動的にプログラムを変換してくれます。
例外を発生させてもいいのか
JavaScript/TypeScriptには、例外という言語機能があります。throw文でエラーオブジェクトを投げるやつのことです。ここでは、「コンポーネントが例外を発生させてもいいのか?」という疑問について考えます。言い換えれば、例外を発生させうるコンポーネントは、純粋なコンポーネントと見なされるのか? ということです。
というのも、例外も副作用の一種とされる場合があります。もしそうであれば、Reactコンポーネントは例外を発生させてはいけないという結論になるかもしれません。
この論点に対しては、答えを出すだけなら話は簡単です。Reactのコンポーネントは、例外を発生させても問題ありません。このことは、以下の2つの事実から分かります。
- ReactにはError Boundaryという仕組みがあり、レンダリング中にコンポーネントが発生させた場合に例外をキャッチできる機能がある。
- そもそもコンポーネントの中で使うReactのAPIが例外を発生させることがある(useに渡されたPromiseがrejectされた場合)。
とはいえ、なぜそのような判断になっているのかについても考えたくなりますね。すこし考察してみましょう。「例外は副作用に含まれるのか?」といった一般論の話はちょっと奥が深すぎるので、ここではあくまでReactの文脈で説明します。
まず、Reactでは、コンポーネントが例外を発生させることは「レンダリングの失敗」として扱われているようです。そもそも、コンポーネントが例外を発生させてしまったら、返り値の「指示書」が得られないことになります。そうなると、Reactランタイムは何を表示すればいいのか分からなくなってしまいます。そのため、Reactは失敗時のフロー(Error Boundaryの処理など)に移行します。
そもそも、コンポーネントから返り値という意味での「出力」が得られていないので、Reactはコンポーネントの出力を利用して何かをすることができません。この状況でコンポーネントが純粋か否か考えるのはあまり意味がなさそうです。より地に足のついた言い方としては、「コンポーネントが純粋であることの嬉しさ」は、例外発生時のReactの挙動では発揮されないということです。
そのため、Reactの文脈では、コンポーネントが例外を発生させることは「純粋性を損なうもの」とは見なされないと考えてよいでしょう。
純粋性とフック
ここから、本記事最後の、しかし重要な話題に入ります。Reactの関数コンポーネントにおいて特徴的なのがフックの存在です。フックを使うことで、関数コンポーネントにステートを持たせるなど、特殊な追加機能を持たせることができます。フックもまた、純粋性の議論においては混乱のもとになってしまう要素です。
フックを使うと純粋性が失われる?
単純に考えると、Reactのフックを使うとコンポーネントが純粋ではなくなってしまうのではないか? という疑問が出てきます。例えば、以下のようなコンポーネントを考えてみましょう。
const Counter: React.FC = () => {
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button type="button" onClick={() => setCount(count + 1)}>
インクリメント
</button>
</div>
);
};
純粋性の定義を愚直に適用すると、useStateの呼び出しは副作用なのではないかと思いますよね。何しろ、コンポーネント内でいきなり謎の関数を呼び出して、countという値をどこかから取得しています。そして、その値は返り値のJSX式の中で使われています。そうなると、countの値をどこかから取得したのは副作用ではないか? と思ってしまいます。
しかし、実際のところ、Reactの世界では、上記のようなコンポーネントは純粋なコンポーネントだと見なされます。
この点が、Reactにおける純粋性の定義において特殊な部分です。関数型プログラミングの一般論としては副作用としか思えないフックの呼び出しも、Reactの文脈では純粋性を損なうものではないのです。この記事の序盤で「Reactの文脈での純粋性の定義」を考える必要があると説明しましたが、それは主にフックの存在が理由です。
結論としては、Reactではフックの返り値も、コンポーネントに対する入力として扱うべきです。このように考えることで、上記のCounterコンポーネントは純粋なコンポーネントであると結論付けられます。というのも、Counterの出力(Counterの結果のJSX式)に含まれるのはcountとsetCountですが、これらはどちらもuseStateの返り値です。これら、countとsetCountがCounterコンポーネントに対する入力であると考えれば、Counterコンポーネントは同じ入力に対しては同じ出力を返しますから、純粋なコンポーネントであると考えられます。
まとめると、Reactにおけるコンポーネントの純粋性を考えるとき、コンポーネントの入力としては、「引数 (props)」だけでなく「フックの返り値」も含むということです。これまでの例ではpropsがあまり出てこなかったので、ちゃんとpropsとフックの返り値を両方使うコンポーネントの例を出しておきます。
const Counter: React.FC<{ unit: string }> = ({ unit }) => {
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count} {unit}</p>
<button type="button" onClick={() => setCount(count + 1)}>
インクリメント
</button>
</div>
);
};
このCounterコンポーネントはpropsとしてunitを受け取り、useStateの返り値であるcountとsetCountも使っています。これらはすべてCounterコンポーネントに対する入力ですので、Counterコンポーネントは純粋なコンポーネントであると考えられます。
フックの返り値をさらに考察する
とはいえ、Reactにおける「フックの返り値もコンポーネントに対する入力として扱う」という考え方は、関数型プログラミング一般における純粋性の考え方と一見して乖離しています。そのため、ここにまだ違和感を覚える方もいるかもしれません。そこで、フックと純粋性の関係性をどう解釈すればいいか、もう少し掘り下げて考えてみましょう。
フックが提供する本質的な役割はいくつかあると考えられますが、その中でも重要なのが、コンポーネントの記憶領域を提供することです。コンポーネントは、useStateフックやuseReducerフックを使えばステートを持つことができます。これらはコンポーネントの記憶領域ステートとして用いることで、状態の保存を行います。また、useMemoやuseCallback、useRefなども、コンポーネントの記憶領域を利用するフックです。
コンポーネントの記憶領域という考えに基づくと、コンポーネントに対する入力とは、props及びコンポーネントの記憶領域のある時点でのスナップショットであると言えます[6]。つまり、特定のprops及び記憶領域の状態に対して、純粋なコンポーネントならば常に同じ出力を返さなければならないということです。
特に、useStateを用いてcountステートを得るのは、コンポーネントの記憶領域からの読み出しのためにそういうAPIを使わなければいけないからです。もし何かがまかり間違えば、Reactフックの代わりに次のようなAPIになっていたかもしれません[7]。
const Counter: React.FC<{ unit: string }> = (props, memory, updateMemory) => {
const { unit } = props;
const { count } = memory;
return (
<div>
<p>カウント: {count} {unit}</p>
<button type="button" onClick={() => {
updateMemory({ count: count + 1 });
}}>
インクリメント
</button>
</div>
);
};
このようなAPIであれば、「コンポーネントの記憶領域のスナップショット」をコンポーネントに対する入力として考えるのは自然なことだと分かります。実際のReactも、フックというAPIを使っているだけで、本質的にはコンポーネントはこのように記憶領域のスナップショットを入力として使っているのですから、それを前提にコンポーネントの純粋性を考えなければならないのです。
Reactが上記のようなAPIではなくフックを採用した理由は、この記事では深入りしませんが、フックのほうがAPIとして優れているからでしょう。例えば、フックのほうが、よりコンポーネントのロジックを凝集させることができます。
フックの中に副作用を書いていい?
コンポーネントの中のどこに副作用を書いていい・書いてはいけないのかという話については、これまでのところ、以下のことが分かっていましたね。
- コンポーネントの返り値の計算に副作用が含まれてはいけない。
- イベントハンドラの中には副作用を書いてもいい。
ここにフックの概念が加わったことで、「フックの中に副作用を書いていいのか?」という疑問が出てきます。結論から言うと、フックによって異なります。
分かりやすいところでいえば、useMemoの中には副作用を書いてはいけませんね。このことは、useMemoはレンダリングの最適化のために使われるフックだということを理解していれば分かりやすいでしょう。
// 最適化前
const Counter: React.FC<{ unit: string }> = ({ unit }) => {
const [count, setCount] = useState(0);
const formatter = new Intl.NumberFormat("ja-JP");
const countString = formatter.format(count);
return (
<div>
<p>カウント: {countString} {unit}</p>
<button type="button" onClick={() => setCount(count + 1)}>
インクリメント
</button>
</div>
);
};
// 最適化後
const Counter: React.FC<{ unit: string }> = ({ unit }) => {
const [count, setCount] = useState(0);
const countString = useMemo(() => {
const formatter = new Intl.NumberFormat("ja-JP");
return formatter.format(count);
}, [count]);
return (
<div>
<p>カウント: {countString} {unit}</p>
<button type="button" onClick={() => setCount(count + 1)}>
インクリメント
</button>
</div>
);
};
つまり、useMemoの中(コールバックの中)のコードは、コンポーネントの返り値の計算の一部です。よって、useMemoの中のコードに副作用があると、コンポーネントの返り値の計算に副作用が含まれてしまうことになります。したがって、useMemoの中に副作用を書いてはいけません。
別の例として、useEffectの中に副作用を書くことは、だめではありません。例えば、useEffectの次の使用例では、useEffectの中に副作用が書かれています。具体的には、document.addEventListener
でイベントリスナを登録するという副作用です。
useEffect(() => {
const controller = new AbortController();
document.addEventListener("scroll", () => {
// 何かする
}, {
passive: true,
signal: controller.signal,
});
return () => {
controller.abort();
};
}, []);
useEffectの中に副作用が書ける理由は、useEffectの中のコードはコンポーネントの返り値の計算に含まれないからです。useEffectの中のコードは、コンポーネントのレンダリング結果が確定してコンポーネントがマウントされたことを契機に実行されます。
つまり、どちらかというと、useEffectの中のコードは指示の一部だということです。「コンポーネントがマウントされたらこのエフェクトを実行してください」ということですね。イベントハンドラのときと同じ理屈で、「このエフェクト」というのが同じ意味なのであれば、その中に副作用が含まれていることはコンポーネントの純粋性には影響しません。
余談: useEffectのややこしい話題
useEffectには副作用を書くことができますが、実は、だからといって「useEffectの中にはどんな副作用も無秩序に書いていい」ということにはなりません。純粋性とはまた少し違う文脈で、useEffectは正しい使い方をしなければならないのです。
これについては筆者の別の記事で取り扱っていますので、興味がある方はご覧ください。ただし、先にこの記事の内容をよく理解してからのほうがいいでしょう。あちらの記事では「副作用」という言葉についてまた別の切り口で深堀りしていますので、この記事と同時に読むと混乱するかもしれません。
まとめ
この記事では、Reactコンポーネントが純粋であるとはどういうことかについて解説しました。重要なポイントをまとめます。
- Reactコンポーネントは、同じ入力に対しては同じ出力を返す必要がある。
- 入力には、引数 (props) だけでなくフックの返り値も含まれる。
- 同じ出力とは、関数コンポーネントの返り値のJSX式の値が「意味が同じ」であることを指す。
- 純粋性は、関数コンポーネントに副作用がないこととも言い換えられる。
- これは、Reactコンポーネントの返り値の計算に副作用が含まれないことを指す。
- JSX式の中に含まれるイベントハンドラの中に副作用があることは問題ない。これは、その副作用は返り値の計算に含まれるわけではないから。
- フックの中のコードについては、それが返り値の計算に含まれるかどうかで判断する。
Reactコンポーネントの純粋性についてなるべく多くの人に納得してもらえるように、この記事ではさまざまな言い方で説明を試みました。その中のどれか一つでも、あなたの腑に落ちる説明があれば幸いです。人によっては説明が冗長に感じられたかもしれませんが、そういうコンセプトの記事なのでご容赦ください。
-
純粋なコンポーネントという考え方をクラスコンポーネントにも拡張することはもちろん可能です。この記事では、そうしても説明をむやみに複雑にするだけで、関数コンポーネントだけ考えても十分本質を理解できると考え、クラスコンポーネントは脇に置いておくことにします。 ↩︎
-
正確には、参照透過性は「関数」ではなく「式」が持つ性質とされます。そのため、より正確に言えば、「その関数呼び出しの式が参照透過性を持つ」と言うべきかもしれません。 ↩︎
-
「現段階では」とか「基本的な」とかいって予防線を張っていることから分かるように、これはあくまでメンタルモデルの話です。実際のオブジェクトの対して同一性判定を実装することは、これくらい単純な状況ならできるでしょう。しかし、この先話がややこしくなってくるとどうしても実装が不可能な概念(関数の同一性)が出てきますので、100%実装ベースの理解がこの先通用するわけではないことはご理解ください。 ↩︎
-
別々に作られた2つの関数が「同じ」であるとはどういうことか? というのもまた、関数型プログラミング等の文脈でも取り扱われる奥の深い話題です。しかし、筆者もそこまで詳しくない上にReactの文脈ではそこまで重要な話でもないので、ここではお茶を濁して「実行したときの挙動がまったく同じだから意味が同じ」のようなちょっと抽象的な説明に留めておきます。 ↩︎
-
実際の変換結果はuseCallbackなどがそのまま使われているわけではなく、もうちょっと違う仕組みで動いています。しかし、大まかな理解としてはそのように理解しておけば大丈夫でしょう。正確に知りたい方は、React Compilerの仕組みを調べてみましょう。 ↩︎
-
単に「記憶領域の現在の状態」とかではなく「ある時点でのスナップショット」と言っていることにも理由があります。実は、Reactのトランジションという機能を使うと、「ステート更新前の状態」と「ステート更新後の状態」のレンダリングが並行的に行われることがあるからです。詳しくは、筆者の別のZenn本『Reactのトランジションで世界を分岐させるハンズオン』をご覧ください。 ↩︎
-
これ実質クラスコンポーネントと同じやん! と思った方、するどいですね。そう考えると、このようなAPIを妄想してもあながち間違いではないということが理解できると同時に、フックのAPIはクラスコンポーネントからさらに進化したものであるということが実感できるでしょう。 ↩︎
Discussion