🔬

最速攻略! Reactの `use` RFC

2022/10/16に公開約14,000字2件のコメント

皆さんこんにちは。最近のReact界隈で話題になっているのは次のRFCです。

https://github.com/reactjs/rfcs/pull/229

そこで、この記事ではさっそくRFCを理解することを目指します。

ただし、このRFCはSuspenseに深く関わるものです。SuspenseはReact 18でもう正式リリースされていますから、この記事ではSuspenseは前提知識とします。もしまだSuspenseをよく知らないのであれば、ぜひ次の記事で学習してください。

https://zenn.dev/uhyo/books/react-concurrent-handson

また、RFCはあくまでReactの新機能のアイデアを公開するものであり、これが必ず実装されるとは限らない点にご注意ください。例えば、過去にはuseEventというRFCが注目を集めていましたが、意見が集まった結果としてそのRFCは実装されずにクローズされました(RFCが無駄だったというわけではなく、再度検討してよりアイデアがブラッシュアップされることになります)。

新しい use API

このRFCには大きく分けて2つの特徴があります。一つはServer Componentsに関係するもので、もう一つはどんなコンポーネントでも使えるものです。現在の状況だと後者のほうに興味がある方が多いでしょうから、そちらを中心に据えて説明します。

このRFCでは、新しいuseという関数が実装されます。RFCの説明では、これは「特殊なフック」であるとされています。使い方は次のようになります(RFCから引用)。

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>
  );
}

fetchNotefetchNoteAuthorの返り値であることから察せられるように、useに渡されるのはPromiseです。コードを見ると、useにPromiseを渡すとその中身を取得できています。おおよそ、useのシグネチャは次のようであると考えられます。

const use: <T>(promise: Promise<T>) => T;

つまり、awaitのようにPromiseから中身を取り出すのがuseの役目ということです。

useに渡されたPromiseが未解決だった場合の挙動は従来と同じです。つまり、その場合はuseの返り値を用意できないため、コンポーネントのサスペンドが発生してその関数の実行は中断され、Promiseが解決してから再試行されます。

従来のSuspenseと異なる点は、従来はReactは「投げられたPromiseが解決したら再度レンダリングを試みる」ことのみをサポートしており、Promiseの中身を取り出すことは我々に任されていました。それに加えて、useではPromiseの中身を取り出すところまでやってくれるのが新しい点です。

従来はReactのSuspense機構を生で使うのが難しく、何らかのライブラリを経由して使うのが主流でした。useの登場により簡単なケースならライブラリを使わずにPromiseを取り扱えるようになります。

特殊なフック?

RFCでは、useはフックの一種であるとされています。しかし、useは従来のフックと違うところがあります。それは、条件分岐内でも使用してよいということです。上の例でも、if文の中でuseが使われているのが見て取れます。

Reactにおけるフックのルールは次の2つに大別されました。

  • 関数コンポーネントの中(および関数コンポーネントから呼び出されるカスタムフックの中)でしか使用できない。
  • 常に同じ順序で同じ数だけ呼ばれなければならない。(=条件分岐により、呼んだり呼ばなかったりすることはできない)

useは、この2つのルールのうち前者のみを制約として持ち、後者のルールは適用されないことになります。

なぜuseに後者のルールが適用されないのかは、そもそもなぜこのようなフックのルールが存在しているのかを考えれば理解できます。

まず、前者のルールは「フックはコンポーネントに属する」ことから説明できます。例えばuseStateはコンポーネントのステートを宣言するものですから、コンポーネントの外で使うとどのコンポーネントのステートを宣言しているのか分からないので意味がありません。また、useContextは、コンポーネントがコンポーネントツリーのどこに存在するのか分からないとコンテキストの値を取得できませんから、これもコンポーネントに属しています。

次に後者のルールは「フックがコンポーネントの記憶領域にアクセスする」ことから説明できます。useStateがコンポーネント内にステートを用意するのはもちろん、useRefuseMemoなどもコンポーネント内の記憶領域を利用しています。他にも、useEffectもクリーンアップ関数がコンポーネント内の記憶領域に保存されていると考えられます。クラスコンポーネント時代にthisとして自由にアクセスできた記憶領域が、関数コンポーネントではフックの裏に隠されていると考えると分かりやすいでしょう。

フックのAPIの特徴は、コンポーネントの記憶領域内のデータに名前を付けないということです。代わりに、「何番目のフック用の記憶領域なのか」を頼りにフックの記憶領域の読み書きが行われます。この点については詳しい解説記事が探せばあると思うのでここでは詳しい説明を省略します。

本題に戻ると、なぜuseには後者のルールが適用されないのでしょうか。それは「コンポーネント内の記憶領域を使用しないから」であると考えられます。useは与えられたPromiseの中身を取り出すだけなので、実は記憶領域が必要ありません。

一方で、前者の制約は必要です。なぜなら、Promiseがまだ解決していない場合にコンポーネントをサスペンドさせる必要があるからです。

以上のことを考えると、useは特殊なフックというよりも、フックの新しい分類を立ち上げる存在であると考えられます。従来のフックたちを「記憶領域を必要とするフック」として、新たに「記憶領域を必要としないフック」という分類ができたというイメージです。

両者に別々の名前を付けてもよいと思うのですが、用語を増やすとユーザーが混乱するでしょうからそれは避けたのではないかと思います。また、use以外に今後「記憶領域を必要としないフック」が出てくるかどうかは不透明なので(それを防ぐためにuseという超汎用的な名前を付けたとも推測できます)、今回useは「特殊なフック」という立ち位置にしたのでしょう。

useは何を解決するのか

以下の記事を読んだ方は、ReactのSuspenseを利用してコンポーネントを書く方法を理解したはずです。

https://zenn.dev/uhyo/books/react-concurrent-handson

この記事を読むと分かる通り、コンポーネントのサスペンドを有効に活用するためにはコンポーネントの外部にデータを保存することが必要でした。上記の記事の6章「コンポーネントの外部にデータを持とう」では、データの保存のためにグローバルなキャッシュキーを用意する必要があると説明しました。実際にこれは現在SWRTanStack Query(元React Query)で使われているアプローチです。

また、Promiseをthrowするとコンポーネントが必ずサスペンドするので、コンポーネントをサスペンドさせるかどうかの判断をするためには「読み込み完了したかどうか」というフラグを別途持っておくことが必要です。上記の記事の7章「Render-as-you-fetchパターンの実装」においても、Promiseと読み込み完了フラグをセットにしたLoadableというデータ構造を紹介しました。

以上のことは、Promiseが一級市民ではなかったと説明することができます。Promiseは、JavaScriptの言語仕様においては非同期処理そのものを表す汎用性の高いオブジェクトです。しかし、ReactのSuspenseの文脈においては今のところコンポーネントをサスペンドさせる道具という程度の位置づけです。そのため、アプリケーションロジックにおいて便利に使われるPromiseをコンポーネントツリーに持ち込む際には、useSWR, useQueryあるいはLoadableといった中間層が必要になっていました。

今回のRFCで導入されるuseはこのギャップを埋めてくれます。Reactコンポーネント内から直接Promiseの中身を取り出せるようにすることで、Reactコンポーネント内においてもPromiseを「非同期処理そのもの」として取り扱うことができます。筆者は、中間層を除去することによってReactとPromiseの親和性が向上し、Reactにおける非同期処理の取り扱いがよりエンジニアにとって分かりやすくなると期待しています。

RFCのタイトルもよく見ると「First class support for promises and async/await」となっています。これは、上で説明したように、ReactがPromiseを直接サポートするということを表しているのでしょう(async/awaitがどう関わってくるのかについてはもう少しあとで説明します)。

useはキャッシュと組み合わせよう

RFCには次のような注意が書かれています。これが意味するところについて説明します。

Caveat: Data requests must be cached between replays

冒頭のuseの例を再掲します。

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>
  );
}

ここで使われているfetchNoteの実装がこんな感じだとすると、このコンポーネントは再レンダリングのたびに再びfetchが発火してしまうことになります。例えば、idはそのままでshouldIncludeAuthorだけ変化した場合も、fetchNote(id)が再実行されます。それどころか、useはサスペンド後に関数コンポーネントを再実行するので、1回のレンダリングでも複数回発火してしまいます。

const fetchNote = async (id: string) => {
  const res = await fetch(`/api/note/${id}`);
  return res.json();
};

このように、useに対してPromiseを渡すという性質上、うまくやらないと無駄な非同期処理が発生してしまいます。例えば同じidに対するリクエストは一定時間キャッシュしておくというような、何らかのキャッシュの機構はいまだに必要だということです。

「それなら1回だけfetchNoteを呼ぶように制御すればいいじゃん」と思われそうですが、Reactの思想的にはコンポーネントは極力冪等にして、キャッシュを使ってパフォーマンスを確保してほしいようです。また、コンポーネントの最初のレンダリングでサスペンドする場合を考えると、どのみちコンポーネントの外部にデータを持つ必要があるため、面倒くささはそこまで減っていません。

キャッシュをわざわざ導入するのは面倒くさいように思えますが、現在のところSuspenseはそもそもuseQueryなどサードパーティのライブラリと組み合わせて使うことが多く、その場合はこのようなキャッシュ制御は元々ライブラリが行ってくれています。

つまるところ、非同期データの読み込みに関する役割の分担は、useの登場前後で次のように変化することになります。

処理 出力 use前の分担 use後の分担
1 非同期データの読み込み Promise ユーザーのコード ユーザーのコード
2 非同期データのキャッシュ Promise サードパーティ(useQueryなど) サードパーティ(useQueryなど)
3 コンポーネントをサスペンドさせるかどうかの制御 Promiseをthrowする/しない サードパーティ(useQueryなど) React本体

つまり、非同期データの読み込み中にコンポーネントをサスペンドするという一連の処理を1~3に分けるとすると、useの登場によって3がサードパーティのライブラリからReact本体に移管されることになります。

そうなると、useSWRuseQueryを使用する顕著な理由として残るのはキャッシュの制御をしてくれる点だということになりますね。

また、コンポーネントをサスペンドさせるかどうかの制御をしてもらうという責務をuseSWRuseQueryから除去したとすると、これらがフックである理由はもはや無くなります。ライブラリ側からすると、「Promiseをthrowする」というReact特有のプロトコルに縛られる必要が無くなり、単なるPromiseを出力すれば良くなります。これにより、ライブラリ側はもはやReact専用のAPIを提供する必要がなくなり、ライブラリ側にもメリットがあります。

このように、ライブラリとReact本体の間のインターフェースが単なるPromiseになったというのも目覚ましい進化だと言えます。これもPromiseの一級市民化の一環です。

さらに言えば、実はRFCではuseと相性の良いcache APIのRFCも出てくるということが予告されています。つまり、上の表の2もReact本体に移管される可能性があります。

そうなると、非同期データフェッチングのライブラリは不要になるか、あるいは「キャッシュ戦略」を提供する薄いライブラリとして残るという未来が予想できます。その方向性にベットしてReactアプリケーションを設計するのも悪くない選択でしょう。

useのためのReactコアの変化

useはただ新しいAPIが実装されるというだけではなく、それに対応するためにReactのコアにも変化が加えられます。

useのRFCではReactがPromiseの中身の読み取りを担当するということを思い出してください。つまり、キャッシュの機構が入ったとしても、非同期処理の結果はPromiseでよいということになります。すでにキャッシュされていた場合はすでに解決されたPromiseが返されます。

このように、キャッシュの有無にかかわらずPromiseが結果となるというのはインターフェースの簡潔化に有効であり、JavaScriptにおいてasync関数が常にPromiseを返すという事情にも適合し、JavaScriptフレンドリーです。

ところが、ひとつ問題があります。それは、JavaScriptにおいてはPromiseから同期的に値を読みだす方法が存在しないということです。すでに解決済みのPromiseだとしても、かならず非同期処理で読みだす必要があります。

// 解決済みのPromiseを作成
const promise = Promise.resolve("chu");
// Promiseから値を読みだす
promise.then((value) => console.log(value));
// 同期的な処理
console.log("pika");

上の例では「pika」「chu」の順に出力されます。このように、すでに解決済みのPromiseだとしても、同期的な処理のほうが読み出しより先に処理されます。

一方で、Reactのレンダリングは同期処理です。これは、関数コンポーネントがasync関数ではなく普通の関数であることから分かります。

これが意味するところは、たとえ解決済みのPromiseだとしても、Reactコンポーネントのレンダリング中にPromiseの中身を取得することは不可能だということです。従来のReactではこれに対して「読み込み完了しているならPromiseをthrowしない」という形で対処していましたが、これはPromiseの読み込み完了状態をPromiseとは別に持っている必要があるのでPromiseが一級市民とは言えませんでした。

useの魔法

前の例を思い返すと、同期的に実行されるコンポーネントの中でuseを使うとPromiseの中身が同期的に読み出せるというAPIになっていました。普通に考えるとこれは不可能なので、この魔法のような挙動を実現するためにReactが裏で何かやってくれているということになります。

もちろんuseもSuspenseをベースとしているので、Promiseが読み込み中だったときはコンポーネントをサスペンドさせます(そのPromiseをthrowしたときと同等の挙動)。その場合、Promiseが解決したときは再度レンダリングが行われます。

ここで問題となるのは、リクエストがすでにキャッシュされていた場合においてuseに渡されるのは「すぐに解決されるPromise」であるということです。つまり、Reactはuseに渡されたPromiseが「すぐに解決されるかどうか」を判別して、サスペンドするかどうか判断する必要があるということです。

この判断機構が、useに伴ってReactコアに新たに実装される機能であると考えられます。そして、これがどのように行われるのかについては実はRFCをよく読むと書いてあります。

What we can do in this case is rely on the fact that the promise returned from fetchTodo will resolve in a microtask. Rather than suspend, React will wait for the microtask queue to flush. If the promise resolves within that period, React can immediately replay the component and resume rendering, without triggering a Suspense fallback. Otherwise, React must assume that fresh data was requested, and will suspend like normal.

つまり、「Promiseがすぐに解決される」ということを「レンダリング後にマイクロタスクキューが全部消化されるまでに解決される」として定義し、この場合はコンポーネントのレンダリングが成功したと判断し、サスペンドを省略します。

ただし、Promiseの中身が判明してからコンポーネントの再レンダリングをするという機構は依然として必要となるので、1回のレンダリングで関数コンポーネントが複数回呼び出されるのは従来と変わりません。

従来のサスペンドにおける「レンダリング→サスペンド発生→完了したら再レンダリング」というフローは変わらないまま、これが一瞬で終了したらサスペンド扱いにならずにレンダリング成功と見なされるようになると理解しましょう。

これにより、従来は完全に同期的だった「レンダリング」という処理が、マイクロタスクレベルの遅延であれば待ってくれるという意味で、非同期的な処理になったと考えることができます。この点が今回のRFCの本質的なポイントでしょう。

useの裏側と記憶領域

この記事の前半で「useはコンポーネント内の記憶領域を必要としないので条件分岐の中で呼び出すことができる」と説明しました。しかし、useが記憶領域をまったく使用しないというわけではありません。

useに渡されたPromiseの結果はどこかに保存されており、再レンダリング時はuseはそちらの記憶領域から結果を読み込んで返すことによって、useにPromiseを渡すとその中身が取り出されるという挙動を実現しています。

そうなると結局記憶領域が必要になりますね。しかし、ここで使われる記憶領域は「コンポーネント単位」ではなく「レンダリング単位」です。つまり、use用の記憶領域はレンダリング時に確保され、そのレンダリングが成功裡に完了すれば記憶領域は破棄されます。

そして、useも普通のフックと同様に「何番目の呼び出しか」に依存して記憶領域からデータを読みだします。

それにも関わらずuseを条件分岐の中で使用できるのは、レンダリングの最中に条件分岐の結果が変わることはないという仮定を設けているからです。Reactコンポーネントはもともと純粋性(propsやstateが同じなら同じ結果になること)が必要とされています。この性質から、propsやstateが同じならコンポーネント内の計算も同じように行われるはずで、if文の分岐が前回と異なる方向に進むことはないだろうから、分岐の中でuseを使われても大丈夫ということです[1]

次の例(再掲)においても、if文の中を通るかどうかはshouldIncludeAuthorというpropのみに依存しているので、propsが変わらなければuseの実行回数や順序も変わらないはずです。この仮定により、条件分岐の中でuseを使っても問題ないのです。

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>
  );
}

逆に言えば、純粋性を崩す形で条件分岐の中でuseを使うのはやはり不可能です。例えば次のようにするとうまく動かないはずです。

// これはだめ
const note = use(fetchNote(id));

if (Math.random() < 0.1) {
  // このようにuseを使うのはだめ
  const rareData = use(fetchRareData(id));
  return (...);
}

return (...);

非純粋なReactコンポーネントを書く人は今どきいないとは思いますが、このようにReactの新しいAPIはどんどん純粋性の上に乗っかってきています。純粋なコンポーネントを書く習慣を大事にしましょう。

ちなみに、「コンポーネント単位の記憶領域」は、コンポーネントが初回レンダリング時にサスペンドしてしまった場合は破棄されます。これにより、useのような機能を現在のReactの機能を使って再現するのが難しくなっています。useはコンポーネント単位ではなくレンダリング単位の記憶領域という概念を導入してこれを克服しているのです。この点が、わざわざReactのコアにuseを導入する理由となります。useにより、(Promiseを返す側でキャッシュが依然として必要になるのはすでに説明した通りですが)Promiseの状態をトラッキングする処理をコンポーネントの外部で行う必要がなくなるのです。

余談: useのほかの用法

useはコンポーネント単位の記憶領域を使用しないという点で従来のフックとは異なると説明しました。

実は、皆さんが普段よく使う既存のフックの中にも、コンポーネント単位の記憶領域を使用しないものが紛れています。そう、それはuseContextです。

本質的にはuseContextは記憶領域を使用しないため条件分岐の中で使ったりしても別に問題なかったのですが、フックという統一された機構の上に乗ったために、useContextにも「条件分岐の中で使えない」というルールが適用されていたのです。

ということで、RFCではuseに対する将来的な拡張として、use(context)とすることでuseContextと同様にコンテキストの中身を取り出せるようになるかもしれないと述べられています。もちろん、use(context)useContext(context)とは異なり、条件分岐の中で使用可能です。

useContextについてはたまたまuse(context)としても違和感のないAPIであるためuseに統合されそうですが、それ以外に記憶領域が必要ないフックが現れたときにuseに続く第2の「条件分岐の中でも使えるフック」となるかどうかは不透明です。わざわざuseという特別な名前をここで持ち出したとなれば、そのような方向性には進まなさそうにも思われますが。

サーバーサイドasyncコンポーネント

ここで、useを離れて次の話題に移ります。同じRFCでは「Server Componentではコンポーネントをasync関数にできる」という提案もされています。

軽く復習しておくと、React Server Componentsではコンポーネントがサーバーサイド用とクライアント用に分類され、両者のレンダリング結果を組み合わせることでReactアプリケーション全体のレンダリングが完成するという構成になります。React Server Componentsはまだ正式リリースされていません。

このRFCでは、サーバー側のコンポーネントをこのようにasyncで書けるとされています(RFCから引用)。

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>
  );
}

async関数であるということは、Promiseの中身を得るためにuseではなく標準のawaitを使えるということです。Server Componentの主要なユースケースとしてデータの取得がありますから、これは相性がよいですね。

この機能の導入により、コンポーネントでPromiseを扱うベストプラクティスがサーバーサイド(await)とクライアントサイド(use)で異なることになりますが、async/awaitの導入はそのデメリットを上回るメリットがあると判断されたことからこのRFCに至りました。詳しい理由の説明はRFCにありますから気になる方は読んでみましょう。

ちなみに、RFCを読む限り、asyncコンポーネントが返したPromiseは普通に解決されるまで待たれます。サスペンドとかそういうややこしい概念はありません。

その裏返しとして、asyncコンポーネントではフックが使えません。フックが出る前の関数コンポーネントのような味わいです。これについては、そもそもサーバーコンポーネントはステートレスな計算をユースケースとしており、useStateuseEffectなどは元々サーバーコンポーネントでは使えなかったので大した問題ではありません。RFCでも言及されていますが、asyncコンポーネントとuseIdを組み合わせられないのがちょっと不便な程度だと思います。

RFCではFAQとして「クライアントサイドでもasyncコンポーネントをサポートしないのか?」という質問があり、技術的には可能だが、利用にあたって注意すべき点が多くなってしまうので推奨していないという旨の説明がされています。こちらも詳しくはRFCを読んでみましょう。

まとめ

この記事ではReactの新しいRFCに記述されたuseAPIについて説明しました。

useを使う場合、従来Reactコンポーネントで非同期処理を扱う際に必要だった「Promiseをthrowする」というプロトコルをReact内部に隠蔽することができます。それに伴って、Reactとサードパーティライブラリの間のインターフェースが単なるPromiseの受け渡しになります。

Reactを使うエンジニアにとっては、慣れ親しんだPromiseをReactが直接サポートしてくれるのは嬉しいし、Suspenseを取り扱う際にライブラリを介さなくても良いケースが増えるのも魅力的です。一方、ライブラリ側にとってもReact特有のプロトコルをわざわざサポートする必要がなく責務が単純になるという利点があります。

Reactはこれまで、サードパーティライブラリと協調しながら何をReactのコアに導入すべきか慎重に検討してきました。今回のRFCもその流れに乗り、サードパーティライブラリの負担を軽減してくれるものとなっています。その点で、今回もReactらしいRFCだと言えるでしょう。筆者としては、ぜひ導入されてほしいと思います。

好評につき続編ができました↓

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

脚注
  1. 関数の結果だけでなく実行過程にまで制約がかかるという点がやや特殊で、純粋性の定義によっては納得できないかもしれません。副作用がないという意味での純粋性であれば、多分実行過程も同じになりそうです。 ↩︎

GitHubで編集を提案

Discussion

「ご注意: キャッシュは必要!」の

ここで使われているfetchNodeの実装がこんな感じだとすると、実はこのコンポーネントはうまく動きません。

なぜなら、コンポーネントがサスペンドして読み込み完了した場合には再レンダリングが行われるため、その際にfetchが再度実行されて再び読み込み中になってしまうからです。

fetchが再実行されるのはその通りだと思いますが、再び読み込み中にはならないように思われます

https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#reading-the-result-of-a-promise-during-a-replay

React will reuse the result from the previous attempt, and ignore the promise that was created during the replay.

つまり、サスペンドしたコンポーネントが再開される場合 (replay) の再レンダリングにおいては (より正確にはprops/stateが変化していない再レンダリングにおいては)、Reactは新しい (かもしれない) Promiseは無視して、サスペンドした時のPromiseそのものを再利用する (と思われる) ためです
(もしかしたらそんなことは承知の上で、この時点では「Promiseを記憶しない」と説明している都合上「キャシュは必要!」ということにしたのかもしれませんが)

実際Next.jsのテストコードでuse()に渡している非同期関数は以下のようにキャッシュも何もしていない極めて雑なものです
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-rendering/app/ssr-only/slow/page.js

もちろん、無駄な非同期処理を避けるためにキャッシュした方が効率的なのは確かです
が、それは「うまく動かない」からではないですよね

ご指摘ありがとうございます。ここについてはRFCの内容を誤解していました。
記事の内容を修正しました。

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