なんでコンポーネントに副作用があんだよ! 教えはどうなってんだ教えは!
皆さんこんにちは。先日公開した以下の記事は多くの方にご覧いただきありがとうございます。
この記事に対して多く見られた反響のひとつは、コンポーネント内に use(fetchNote(id))
という非同期処理を行うコードが含まれていることに対する違和感です。
function Note({id, shouldIncludeAuthor}) {
// ↓↓↓↓↓
const note = use(fetchNote(id));
let byline = null;
if (shouldIncludeAuthor) {
const author = use(fetchNoteAuthor(note.authorId));
byline = <h2>{author.displayName}</h2>;
}
return (
<div>
<h1>{note.title}</h1>
{byline}
<section>{note.body}</section>
</div>
);
}
しかも、非同期処理を発火するとなればこれは明らかに副作用ですから、これまでの教えではこのような処理はuseEffect
内で行うはずでした。ご存じの方も多いと思いますが、Reactにおける関数コンポーネントというのはしょっちゅう呼び出されるものです。そこに直に副作用が書いてあるとなると、必要以上に非同期処理(fetch
とかの場合はHTTPリクエスト)が発生することになって良くありません。
そこで、この記事では、なぜこのようにコンポーネントの中で副作用を直接呼び出してもよい(とReactチームが考えている)のか分析します。
Suspense for data fetching時代のコンポーネント設計
まず、このように「コンポーネント内からfetch
に類するものが直に発火する」というのは、use
のRFCで初出のアイデアというわけではありません。React 18でSuspense for data fetchingが出た時点で存在したアイデアが、use
の「Promiseを直接取り扱える」という性質によってより強調されたものだと解釈できます。
中核となるアイデアは、「取得された生のデータ」ではなく「非同期的に取得されるという文脈も含めたデータ」をプリミティブなものとして取り回すということです。TypeScriptの言葉で言えば、「T
を取り回すのではなくPromise<T>
を取り回す」ということです。
T
を取り回すのは旧来のやり方で、この場合はloading
などのフラグをセットで持ちまわる必要があります。非常に原始的な例としては、こういう感じです。
// 非常に原始的な例
const [loading, setLoading] = useState(true);
const [note, setNote] = useState<Note | undefined>(undefined);
useEffect(() => {
fetchNote(id).then(setNote);
}, [id])
しかし、Promise<T>
は内部的に「読み込み済みかどうか」といった情報を持っていますから、Promise<T>
をそのままデータとして扱えばloading
を別に持つ必要は要らないはずです。Promiseをデータとして見なすようにするとこうなります。
const notePromise: Promise<Note> = fetchNote(id);
ここで問題があります。それは、「Promiseが読み込み済みかどうかの情報を持っている」とは言ったものの、JavaScriptの言語仕様ではそのような情報を直接(同期的に)参照することができず、Promiseから情報を取り出す方法がthen
(および内部処理でthen
を使う構文であるawait
)しか無いということです。Reactの関数コンポーネントはasync関数ではないので、そのままではPromiseの情報をレンダリングに使うことができませんでした。
React 18が出た当時のアイデアは、生のPromiseに情報を付加したラッパー(React公式はこれをよくリソースと呼んでいます)を用意して、それをコンポーネントで使用するというものです。サスペンドという概念をReactに導入することで、loading
という状態をコンポーネントが明示的に扱わなくてよくなります。
const noteResource: Resource<Note> = fetchNote(id);
// ↓ これはnoteResourceがまだ読み込まれていなかったらサスペンドする
const note: Note = noteResource.get();
そして、このような「リソース」の管理をうまくやってくれるものとして、データフェッチングライブラリがフィーチャーされることになります。useSWR
やuseQuery
などを使っていると「リソース」を扱っている印象がありませんが、いわゆるrender-as-you-fetchパターンを用いる場合は「リソース」を明示的に扱う場面が出てくるでしょう。
use
RFCではこの方向性をさらに進めて、React本体がもうちょっと頑張ればリソースオブジェクトという中間層を無くしてPromiseを直に取り扱えるのではないかというアイデアが提唱されます。そのためのツールがuse
です。
const notePromise: Promise<Note> = fetchNote(id);
// ↓ notePromiseがまだ読み込まれていなかったらサスペンドする
const note: Note = use(notePromise);
以上が、use
が直にPromiseを受け取るというAPI設計の背景です。T
とloading
を別々に持っているよりは設計が良くなっている感じがしますね。
また、リソースからPromiseへの転換についても、設計的な面での進化があります。というのも、「リソース」は必然的にミュータブルなデータになります。読み込み状態が変わるとリソースの状態が変わり、挙動が変わります。つまり、上の例で言えばnoteResource.get()
の挙動はそれをいつ実行したのかによって変化するということです。この意味で、イミュータブルなデータを取り扱うのが望ましいとされるReactのデータフローの中でリソースは異端の存在です。
一方で、Promiseはイミュータブルなデータです。Promiseも内部状態が変化しますが、データの読み出しはthen
を行うしかありません。そのため、Promiseがどういう内部状態のときにthen
を呼んだとしても、得られる結果は(時間の差こそあれ)同じです。この点により、use(notePromise)
は「propsやstate以外の要因でレンダリング結果が変化しない」というReactコンポーネントの要件を満たしているのです。
Reactコンポーネントの冪等性
前節で説明したように、use
を使う世界ではもはやPromiseはデータであると考えましょう。そうすれば、fetchNote
は「非同期処理を発火する関数」ではなく「Promiseというデータを取得する関数」であると再解釈できます。実際、ReactはPromiseをただawait
やthen
されるだけの存在ではなく、それ自身をデータとして扱う方向性を推進しているように見えます。それを裏付けるようにuse
のRFCでも、Promiseオブジェクト自体をキャッシュして使いまわすことについて言及されています。
fetchNote
は一見するとHTTPリクエストを発火させることが主な責務のようですが、これを文字通り副作用であると再解釈すれば、fetchNote
の主な責務は「id
を受け取ってPromiseというデータを返すこと」であると考えられます。データがサーバーサイドで変化する可能性はいったん横に置いておくとしましょう。そうすると、fetchNote
は「同じid
を渡せば同じデータが返ってくる」という性質を持つことになります(Promiseはオブジェクトなので===
の意味で同じにはならないかもしれませんが、データの意味としては同じものが返るはずです)。雑な言い方をすれば、副作用はあるけどそれを除けば参照透過性が満たされているということになります。
use
のRFCでは、Reactコンポーネントが満たすべき性質として冪等 (idempotent) という言葉がしきりに使用されています。ここでの冪等とは、「何回呼び出しても1回だけ呼び出したのと同じ結果になる」ということを意味しています。
Reactコンポーネントは純粋とか参照透過とかいろいろ言われますが、冪等というのはかなりReactが求める実態に近い表現です。なぜなら、ReactではConcurrent Renderingの導入以降、コンポーネントを1回レンダリングする間に関数コンポーネントが複数回呼び出されるという挙動があちこちで取り入れられており、Reactコンポーネントはそれに耐えなければいけないからです。Reactコンポーネントには(関数本体内に)副作用を持たせるなと言いますが、その理由は複数回呼び出されたら複数回副作用が発火してしまい、冪等ではなくなってしまうからです。
そもそもReactコンポーネントに冪等性が求められる理由は、「Reactが期待通りに動作するため」と「Reactに期待する動作を伝えるため」の2つの側面があります。後者は、宣言的UIという特性上、コンポーネントが冪等でなければそもそも要求が明確とは言えません。冪等でない挙動が組み込まれたコンポーネントは、宣言的でないReactの内部機構に何かしら依存しています。
逆に言えば、副作用があったとしても、冪等であればセーフと考えられないこともありません。たとえコンポーネントがfetchNote
を呼び出して副作用が発生するのだとしても、冪等であればReactコンポーネントの質としては問題ありません。そして、fetchNote
の結果を2回目以降キャッシュしておいて、fetchNote
を何回呼び出しても実際のリクエストが1回しか実行されないのだとすれば、fetchNote
は冪等になりますから、それを呼び出しているReactコンポーネントも冪等になります。
この考え方では、fetchNote
はReactコンポーネントからの呼び出しに耐える(=冪等な)実装として用意しておくことになります。これが、「なんでコンポーネントに副作用があんだよ」に対する答えの半分です。
Reactと外部の責任境界
答えの半分と言いましたが、では残りの半分はどこにあるのでしょうか。筆者は、それはReactと外部の責任境界を考えれば見出せると思っています。
これまで見てきた通り、Reactコンポーネント側から見てfetchNote
に求められていることは「Promiseというデータを返すこと」および「冪等であること」です。ポイントはPromiseはもはや単なるデータとしてしか見られておらず、「その場で非同期処理を行うこと」は必ずしもfetchNote
に求められていません。それはfetchNote
内部の実装詳細の話であり、ユーザーであるReactコンポーネント側からは興味のないことです。
別の言い方をすると、ポイントは「コンポーネントがfetchNote
を呼んだからといって、必ずそのタイミングでリクエストを発火しなければいけないとは限らない。fetchNote
側の好きなタイミングで発火すればいい。」ということです。むしろ、キャッシュだけに留まらず、例えば不思議な先読み機構によってfetchNote
を初回に呼び出すより前からもうデータが準備されていたとしても、問題はまったくありません。
fetchNote
を使うコンポーネント側の責務も、「HTTPリクエストを発火してNoteのデータを取得してそれを表示する」ではなく、単に「Noteのデータを表示する」であると解釈すべきです。みんな大好き関心の分離に従えば、コンポーネント側は「Noteを得るのに時間がかかるかもしれない(だからPromiseが得られる)」ということは知っていても構いませんが、それをどのように取得するのかというのはコンポーネント側が知るべきではありません。
ゆえに、筆者の解釈では、fetchNote
というのは一見するとコンポーネントと副作用が直接的に結びつけられたように見えるものの、実態はむしろ逆です。Promiseというよく抽象化されたデータをインターフェースとして、実際のフェッチ処理をReactコンポーネントの関心から引き剝がしたと見なすことができます。
そうなると、問題はfetchNote
という名前にあるのだという気持ちになります。fetch
と付いた名前は、WHATWG fetchの影響もあり実際にリクエストを発生させることを強く想起させます。ですから、どこからかはよく分からないけどデータを取ってくるというイメージに適した動詞をfetch
の代わりに宛てがって流行らせるといいのではないかと思います。何かそういうライブラリが出てきたら面白いですね。
まとめると、「なんでコンポーネントに副作用があんだよ」に対するもう半分の答えは、「副作用はPromiseという抽象の向こうに隠されたので、コンポーネントから見れば副作用ではない」となります。
use(() => promise)
ではだめなのか?
use
の今のデザインでは、use(promise)
という形でuse
を使います。
前回の記事に対する反響を見てみると、そうではなくuse(() => promise)
ではだめだったのかという意見も見られました。
use(promise)
の問題は、promise
が無駄に何回も作られてしまうということです。use(fetchNote(id))
だと、コンポーネントの再レンダリング時にfetchNote(id)
が再び呼ばれることになります。さらに、コンポーネントがサスペンドしたあとに再レンダリングする場合もコンポーネントが関数として再度呼ばれますから、1回のレンダリングの中でfetchNote(id)
が呼ばれることになるわけです。だからこそfetchNote
が冪等であることが重要になります。
この話題について考察するために、具体的なコンポーネントでuse
の動きを復習しましょう。最初の例を簡略化した次のコンポーネントでuse
の挙動を追ってみます。
function Note({ id }) {
const note = use(fetchNote(id));
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
</div>
);
}
ここで<Note id="uhyo" />
をレンダリングしたとすると、Note
のレンダリングが1回完了するまでの手順は次の通りになります。
-
Note
が関数として呼び出される。 -
fetchNote("uhyo")
が呼び出されて、Promiseが返る(これをPromise1としましょう)。 -
use
にPromiseが渡されたので、use
の内部処理でPromise1がthrowされ、サスペンドが発生する。 - Reactは
Note
のレンダリングを中断し、Promise1が解決されるのを待つ。 - Promise1が解決される。Promise1の結果はReact内部に保存される。
- Reactは
Note
のレンダリングを再開する。Note
が関数として再度呼び出される。 -
fetchNote("uhyo")
が呼び出されて、Promiseが返る(これをPromise2としましょう)。 -
use
にPromise2が渡されたが、今回はサスペンド明けの再試行なのでPromise2は無視される。代わりに、5で保存されていたPromise1の結果がuse
の返り値として使用される。 -
Note
が無事に返り値を返し、それをReactがレンダリングしてNote
のレンダリングが完了する。
このように、use
の機構では、use
に渡されたPromiseの結果が出そろうまで関数が繰り返し呼び出され、サスペンドせずに成功したらレンダリングが完了するという流れになります。こうすることで、最後の1回のNote
呼び出しではuse
に渡したPromiseの結果が同期的に取り出されたように見えるというトリックになっています。
しかし、よく見ると分かるように、上のステップ8では「use
にPromise2が渡されたがそれは無視され、代わりにPromise1の結果が返される」という挙動になっていることが分かります。これが許されるのは、コンポーネントに冪等性があればPromise1の結果とPromise2の結果は同じはずだからです。
つまり、レンダリングを試みる→サスペンド→再挑戦、というサイクルの間は、実際に有効なPromiseは最初にuse
に渡されたものだけであって、2回目以降にuse
に渡したPromiseは実は使われていないのです。
このことが、use(() => promise)
を求める理由となります。use
に関数を渡すようにすれば、最初の1回だけ関数を呼び出してもらって2回目以降は呼び出さないようにすることで、無駄なPromiseが作られるのを避けられます。
さらに、既存のReactのAPIにも似たような状況になっているものがあります。それはuseState
の初期値です。useState
の引数には初期値を渡しますが、その初期値が有効なのはコンポーネントが最初にレンダリングされたときだけで、2回目以降のレンダリングのときは渡された値は無視されます。
前回の記事で述べた通り、useState
は「コンポーネント単位の記憶領域」にデータを保存するのに対して、use
は「レンダリング単位の記憶領域」にデータを保存するという違いがあります。どちらも「記憶領域を初期化する際は引数で与えられた値を使い、2回目以降では引数は無視される」という意味でまったく同じです。実はuse
はuseState
に近いフックだったのです。
話を戻すと、useState
の引数は実は関数にできます。
const [arr1, setArr1] = useState([]); // 2回目以降に作られた [] は無視される
const [arr2, setArr2] = useState(() => []); // [] は1回しか作られない
そして、use
とuseState
が近いということを踏まえると、use
をuse(() => promise)
というAPIにして余計なPromiseが作られないようにすることは可能だと考えられます。
ただし、そうすべきかどうかは別の話です。皆さんは、上のuseState
の例だと、useState([])
とuseState(() => [])
のどちらを選択しますか? 筆者はuseState([])
を選択します。なぜなら、レンダリングのたびに無駄な[]
が作られて捨てられるくらいは、全く問題にならないオーバーヘッドだと思うからです。そもそも、例であれば、() => []
も新しい関数オブジェクトを1つ作る構文であるため、どちらもオブジェクトを1つ作っていることには変わりありません。
では、次の例だとどうでしょうか。
const [state1, setState1] = useState(calculateLargeObject());
const [state2, setState2] = useState(() => calculateLargeObject());
これだと、後者を選択したい人が多いのではないでしょうか。その理由は、calculateLargeObject()
は何かオーバーヘッドが大きそうだからです。
こうなると、useState
に関数を渡すかどうかは、そのオーバーヘッドによって決まりそうです。これはつまり、useState
に関数を渡すことはある種の最適化であるということです。
use
とuseState
が非常に似ていることから考えると、use(() => promise)
をやりたいのであれば、その理由も最適化でなければなりません。
逆に言えば、無駄なPromiseが作られても問題ない(特に、無駄なリクエストが発行されたりしない)のであれば、わざわざuse(() => promise)
の形にせずにuse(promise)
であっても問題ないことになります。
そもそも、これまでに主張した通り、Reactコンポーネント内からfetchNote(id)
を呼び出している時点で、fetchNote
に「リクエストの発火」という副作用を期待すべきではありません。Reactコンポーネント側からは、fetchNote
がいつどのようにデータを取得するのかは興味のないことです。
そう考えると、むしろuse(() => promise)
というAPIにしてしまうと、「1回しかリクエストが走らないようにReactが制御してくれる」という期待を与えてしまいます。そのような期待は、コンポーネントにリクエスト発火の責務を持たせるという良くない設計を前提とし、そちらにユーザーを導いてしまいます。そのため、筆者はuse(() => promise)
よりもuse(promise)
の形のほうがむしろ良いのではないかと思います。
そういえば、() => 処理
の形のAPIはuseMemo
もありました。useMemo
は依存配列が変わらない限り関数を呼び出さずに以前の結果を使いまわしてくれるという機能で、これも最適化のために使うものです。useMemo
に関しては、公式ドキュメントに「将来のReactのバージョンでは、依存配列が変わった以外の理由でuseMemo
のキャッシュが破棄されるケースが出てくるかもしれない」旨の注意書きがあることで知られています。あくまで最適化は最適化であって、1回しか呼ばないという保証はしないということです(現行のReactバージョンではキャッシュが破棄されるケースは無さそうですが、それは将来に渡る保証ではありません)。
use
も同じように最適化という立ち位置になることを考えると、use(() => promise)
というAPIだったとしても、その関数が将来にわたって最初の1回しか呼ばれないという保証は恐らくされないと思います。初期のバージョンではそのような挙動をするかもしれませんが、将来にわたって保たなければならない保証をわざわざ増やす理由がありません。
ということで、筆者の考えとしては、use(() => promise)
というAPIになったとしてもそれにロジック上の要件を任せられないので、それならばより簡潔なuse(promise)
でよいと思います。
useMemoだとだめなの?
useMemo
の話題が出たのですこし考えてみましょう。use
に使われないPromiseを渡したくなければ、PromiseをuseMemo
でキャッシュするという手が考えられそうです。つまり、こうです。
function Note({ id }) {
const notePromise = useMemo(() => fetchNote(id), [id]);
const note = use(notePromise);
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
</div>
);
}
これは一見良さそうですが、残念ながら意味がありません。なぜなら、useMemo
は「コンポーネント単位の記憶領域」にキャッシュを保存しますが、コンポーネントが初回レンダリングでサスペンドした場合は「コンポーネント単位の記憶領域」が破棄されてしまうからです。よって、use
に渡すPromiseをキャッシュできません。
use
はそれとは別の「レンダリング単位の記憶領域」をわざわざ導入することによって、サスペンド前とサスペンド後の試行でデータを受け渡しています。
サスペンド時にわざわざ「コンポーネント単位の記憶領域」を破棄する理由は筆者のReact力が足りないので説明できません。理由が分かる方はぜひコメントでご教授ください。
Server Componentで思いっきりDBにアクセスしてるじゃん!
use
のRFCをよく見ると、Server Componentはasync
にできるという文脈でこんなコードが出てきます。
async function Note({id, isEditing}) {
const note = await db.posts.get(id);
return (
<div>
<h1>{note.title}</h1>
<section>{note.body}</section>
{isEditing ? <NoteEditor note={note} /> : null}
</div>
);
}
これは明らかに、コンポーネントの中でデータベースにアクセスするコードに見えます。ここまであからさまにやられると、責務の分離がどうとかそういう言い訳が通用しません。
これについての筆者の理解は、「Server Componentは従来のReactとはメンタルモデルが大きく異なる。Server Componentはクライアント側のReactとうまく統合されたテンプレートエンジンみたいなものであり、再レンダリングという概念がそもそも無いので問題ない」と考えています。use
ではなくawait
が使えるという点からも分かる通り、サスペンドという概念もそもそもありません。だから、ここは非同期処理をコンポーネントからあからさまに呼び出しても問題ないのです。だって便利だし。
まとめ
Q. なんでコンポーネントに副作用があんだよ! 教えはどうなってんだ教えは!
A.
- 副作用があっても冪等ならまあ大丈夫。
- というか、副作用はReactの外の世界に追い出したので副作用と見なさなくてもいい。
Discussion