🏊‍♂️

なんでコンポーネントに副作用があんだよ! 教えはどうなってんだ教えは!

2022/10/18に公開約11,700字

皆さんこんにちは。先日公開した以下の記事は多くの方にご覧いただきありがとうございます。

https://zenn.dev/uhyo/articles/react-use-rfc

この記事に対して多く見られた反響のひとつは、コンポーネント内に 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();

そして、このような「リソース」の管理をうまくやってくれるものとして、データフェッチングライブラリがフィーチャーされることになります。useSWRuseQueryなどを使っていると「リソース」を扱っている印象がありませんが、いわゆるrender-as-you-fetchパターンを用いる場合は「リソース」を明示的に扱う場面が出てくるでしょう。

use RFCではこの方向性をさらに進めて、React本体がもうちょっと頑張ればリソースオブジェクトという中間層を無くしてPromiseを直に取り扱えるのではないかというアイデアが提唱されます。そのためのツールがuseです。

const notePromise: Promise<Note> = fetchNote(id);
// ↓ notePromiseがまだ読み込まれていなかったらサスペンドする
const note: Note = use(notePromise);

以上が、useが直にPromiseを受け取るというAPI設計の背景です。Tloadingを別々に持っているよりは設計が良くなっている感じがしますね。

また、リソースからPromiseへの転換についても、設計的な面での進化があります。というのも、「リソース」は必然的にミュータブルなデータになります。読み込み状態が変わるとリソースの状態が変わり、挙動が変わります。つまり、上の例で言えばnoteResource.get()の挙動はそれをいつ実行したのかによって変化するということです。この意味で、イミュータブルなデータを取り扱うのが望ましいとされるReactのデータフローの中でリソースは異端の存在です。

一方で、Promiseはイミュータブルなデータです。Promiseも内部状態が変化しますが、データの読み出しはthenを行うしかありません。そのため、Promiseがどういう内部状態のときにthenを呼んだとしても、得られる結果は(時間の差こそあれ)同じです。この点により、use(notePromise)は「propsやstate以外の要因でレンダリング結果が変化しない」というReactコンポーネントの要件を満たしているのです。

Reactコンポーネントの冪等性

前節で説明したように、useを使う世界ではもはやPromiseはデータであると考えましょう。そうすれば、fetchNoteは「非同期処理を発火する関数」ではなく「Promiseというデータを取得する関数」であると再解釈できます。実際、ReactはPromiseをただawaitthenされるだけの存在ではなく、それ自身をデータとして扱う方向性を推進しているように見えます。それを裏付けるように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回完了するまでの手順は次の通りになります。

  1. Noteが関数として呼び出される。
  2. fetchNote("uhyo")が呼び出されて、Promiseが返る(これをPromise1としましょう)。
  3. useにPromiseが渡されたので、useの内部処理でPromise1がthrowされ、サスペンドが発生する。
  4. ReactはNoteのレンダリングを中断し、Promise1が解決されるのを待つ。
  5. Promise1が解決される。Promise1の結果はReact内部に保存される。
  6. ReactはNoteのレンダリングを再開する。Noteが関数として再度呼び出される。
  7. fetchNote("uhyo")が呼び出されて、Promiseが返る(これをPromise2としましょう)。
  8. useにPromise2が渡されたが、今回はサスペンド明けの再試行なのでPromise2は無視される。代わりに、5で保存されていたPromise1の結果がuseの返り値として使用される。
  9. 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回目以降では引数は無視される」という意味でまったく同じです。実はuseuseStateに近いフックだったのです。

話を戻すと、useStateの引数は実は関数にできます。

const [arr1, setArr1] = useState([]);       // 2回目以降に作られた [] は無視される
const [arr2, setArr2] = useState(() => []); // [] は1回しか作られない

そして、useuseStateが近いということを踏まえると、useuse(() => promise)というAPIにして余計なPromiseが作られないようにすることは可能だと考えられます。

ただし、そうすべきかどうかは別の話です。皆さんは、上のuseStateの例だと、useState([])useState(() => [])のどちらを選択しますか? 筆者はuseState([])を選択します。なぜなら、レンダリングのたびに無駄な[]が作られて捨てられるくらいは、全く問題にならないオーバーヘッドだと思うからです。そもそも、例であれば、() => []も新しい関数オブジェクトを1つ作る構文であるため、どちらもオブジェクトを1つ作っていることには変わりありません。

では、次の例だとどうでしょうか。

const [state1, setState1] = useState(calculateLargeObject());
const [state2, setState2] = useState(() => calculateLargeObject());

これだと、後者を選択したい人が多いのではないでしょうか。その理由は、calculateLargeObject()は何かオーバーヘッドが大きそうだからです。

こうなると、useStateに関数を渡すかどうかは、そのオーバーヘッドによって決まりそうです。これはつまり、useStateに関数を渡すことはある種の最適化であるということです。

useuseStateが非常に似ていることから考えると、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の外の世界に追い出したので副作用と見なさなくてもいい。
GitHubで編集を提案

Discussion

ログインするとコメントできます