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

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

この記事に対して多く見られた反響のひとつは、コンポーネント内に 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をデータとして見なすようにするとこうなります。

ここで問題があります。それは、「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を別々に持っているよりは設計が良くなっている感じがしませんか。