HomeMokun Engineer Blog

なぜ、use は Promise のキャッシュ判定ができるのか

はじめに

React のドキュメントに、"レンダー中に作成された Promise をサポートしない"と記載されています。
これは今回の記事のタイトルでもある、"なぜ、use は Promise のキャッシュ判定ができるのか"と多いに関係があり、まずこちらを理解している必要があります。
まず、"レンダー中に作成された Promise をサポートしない"とはどういう意味なのか、その理由を説明し、その後に実際の React のコードを見ながら、なぜ、use は Promise のキャッシュ判定ができるのかを説明していきます。

そもそも、レンダー中に作成された Promise をサポートしないとは、どういうことか

レンダー中に作成された Promise とは、以下のようなコードを指します。

import { use } from "react"; const fetchData = async () => { const response = await fetch("/api/data"); return response.json(); }; const DisplayData = () => { const data = use(fetchData()); return ( <div> <p>{data.title}</p> <p>{data.content}</p> </div> ); }; const App = () => { return ( <Suspense fallback={<div>loading<div>}> <DisplayData /> </Suspense> ); };

use によって、Promise がを読み取る場合は、use 内で Promise を Thorw するので、Suspense で包むのが一般的かと思われます。
これは、use というより Suspense の仕様になりますが、Suspend の終了時に再度そのコンポーネントの先頭からレンダリングが行われます。
つまり、この時に use が再実行され、新しい Promise が生成され(これがレンダー中に作成された Promise になります。)再度 Suspend が起き、無限ループが発生します。
これらの無限ループは、React 側では特にケアはされません。
これが、"レンダー中に作成された Promise をサポートしない"という意味になります。

NOTE
use の無限ループが発生した際に、fallback が再び表示されると思われますが、実際には表示されません。
Suspend 時に、props/state の変更がなく、Suspend が起きた場合、前回の Promise を再利用するためです。
ref

どのようにして、Promise のキャッシュを判定しているのか

さて、前述の通り、use はキャッシュされた Promise を利用しないと無限ループが発生し、外部 API へのリクエスト等が多発してしまいます。
なので、use は開発環境ではキャッシュされていない Promise を利用すると以下のような警告が表示されます。

Warning: A component was suspended by an uncached promise. Creating promises inside a Client Component or hook is not yet supported, except via a Suspense-compatible library or framework.

どのようにして、Promise のキャッシュ判定をして、以下のような警告を表示しているのでしょうか?
いよいよ、実際の React のコードを見ていきましょう。

function use<T>(usable: Usable<T>): T { if (usable !== null && typeof usable === 'object') { // $FlowFixMe[method-unbinding] if (typeof usable.then === 'function') { // This is a thenable. const thenable: Thenable<T> = (usable: any); return useThenable(thenable); } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { const context: ReactContext<T> = (usable: any); return readContext(context); } } // eslint-disable-next-line react-internal/safe-string-coercion throw new Error('An unsupported type was passed to use(): ' + String(usable)); }

ref
上記が、use の実際のコードです。
use の引数に then が生えているかどうかで、Promise かどうかを判定しているようです。
Promise だった場合は、最初の if 文の中に入り、useThenable 関数を呼び出しています。

function useThenable<T>(thenable: Thenable<T>): T { // Track the position of the thenable within this fiber. const index = thenableIndexCounter; thenableIndexCounter += 1; if (thenableState === null) { thenableState = createThenableState(); } const result = trackUsedThenable(thenableState, thenable, index);

ref
※ 今回の記事と関係のない箇所は省略しています。
createThenableState は、単なる配列を返しています。
なぜ、わざわざ空の配列を返すのかは、後述します。
そして、use に渡された Promise、createThenableState で作成された配列、そして、index を引数にして、trackUsedThenable を呼び出しています。
trackUsedThenable に use のコアロジックが書かれています。

export function trackUsedThenable<T>( thenableState: ThenableState, thenable: Thenable<T>, index: number, ): T { if (__DEV__ && ReactSharedInternals.actQueue !== null) { ReactSharedInternals.didUsePromise = true; } const trackedThenables = getThenablesFromState(thenableState); const previous = trackedThenables[index]; if (previous === undefined) { trackedThenables.push(thenable); } else { if (previous !== thenable) { // Reuse the previous thenable, and drop the new one. We can assume // they represent the same value, because components are idempotent. if (__DEV__) { const thenableStateDev: ThenableStateDev = (thenableState: any); if (!thenableStateDev.didWarnAboutUncachedPromise) { // We should only warn the first time an uncached thenable is // discovered per component, because if there are multiple, the // subsequent ones are likely derived from the first. // // We track this on the thenableState instead of deduping using the // component name like we usually do, because in the case of a // promise-as-React-node, the owner component is likely different from // the parent that's currently being reconciled. We'd have to track // the owner using state, which we're trying to move away from. Though // since this is dev-only, maybe that'd be OK. // // However, another benefit of doing it this way is we might // eventually have a thenableState per memo/Forget boundary instead // of per component, so this would allow us to have more // granular warnings. thenableStateDev.didWarnAboutUncachedPromise = true; // TODO: This warning should link to a corresponding docs page. console.error( 'A component was suspended by an uncached promise. Creating ' + 'promises inside a Client Component or hook is not yet ' + 'supported, except via a Suspense-compatible library or framework.', ); } } // Avoid an unhandled rejection errors for the Promises that we'll // intentionally ignore. thenable.then(noop, noop); thenable = previous; } }

ref
※ 今回の記事と関係のない箇所は省略しています。
結論から先に言うと、上記 else 文内の if 文の previous !== thenable が Promise のキャッシュ判定をしている箇所です。
この if 文に入ると、console.error で警告が表示されます。
順番に追ってみていきましょう。
まず、getThenablesFromState では、そのまま配列を返しています。
これを index で取り出し、previous に代入して、previous が undefined だった場合は、trackedThenables に thenable を push しています。
初回レンダリング時には、previous は undefined になるので、trackedThenables に thenable が push されます。
問題は、else の中です。
先ほど、言ったように、初回レンダリング時には、previous は undefined になるので、else の中には入りません。
else の中に入るのは、2 回目以降のレンダリング時です。
ですが、初回レンダリング時でもキャッシュされていない Promise が渡された場合は、警告が表示されます。
なぜ、初回レンダリング時でも警告が表示されるのでしょうか?
答えは、Strict Mode です。
Strict Mode により、コンポーネントが 2 回実行され、trackedThenables に thenable がある状態で、use が再実行されるので、else の中に入ります。
その時に、previous と thenable が異なる場合は、レンダリング中に作成された Promise とみなされ警告が表示されます。
createThenableState で空の配列を返す理由は、Promise を tracking して、同じ Promise なのか比較するためなんですね。

まとめ

結論、use は Strict Mode で 2 回実行される事を利用して、1 回目と 2 回目の Promise を比較して、異なる Promise だった場合に警告を表示しています。
こういった事も見越して、Strict Mode が導入されたんですかね。
天才的な設計力ですね。