✂️

【React】Recoil + DataLoader で学ぶ分割統治

2023/12/10に公開

最近、自分の周りで「APIのデータを取得してきてどうこうするようないわゆる非同期処理を React 上で書くのがつらそうな人」というのをたくさん見る気がするので、ここでは僕が普段用いることが多いテクニックをご紹介します。
本記事の最後にはここで解説したアーキテクチャを適用して作成したサンプルアプリと Github リポジトリへのリンクも張ってありますので、どうかお付き合いください。(そこだけ見てホーンとするのもアリです)

本記事で分かること

  • Suspense の具体的な使いどころ
  • 分割統治法へのざっくりとした理解

本記事では解説しないこと

  • Recoil についての詳細な仕様
  • Suspense についての詳細な仕様
  • バックエンド側の詳細な設計

React と非同期処理のつらみ

なぜ React 上で非同期処理を扱うのはつらいのでしょうか?

それは React の設計思想との相性の悪さにあります。

React は、データから JSX を返す 関数 のような存在であるにも関わらず、ここに非同期処理が絡まってくると突然副作用を考慮しなければならなくなるからです。

例えば、JSON色付け係の嗜みである WebAPI からのデータを表示するような処理では

データフェッチ開始 → 待機 → 表示 (エラーが発生した場合はエラー表示)

と、1つのデータを表示するために3つ(エラー表示も含めれば4つ!)もの状態を考える必要があるのです。

しかし考えてみてください、JavaScript にはもともと Promise という、非同期処理のための便利な概念が存在しているはずです。

にもかかわらず、これを React 上で表現しようとすると、これらをわざわざ分解して表現する苦行を強いられることになります。
加えて実際には単体のコンポーネントで処理が完結することはなく、取得したデータを更に以下のように props で渡したりしているうちに以下のようなコードになるのではないでしょうか。


function FxxkingUserContainer() {
  const [data, setData] = useState<User>(null);

  useEffect(() => {
    (async () => {
      const res = await fetch("/api/sit/user");
      setData(await res.json());
    })();
  }, []);


  return data == null ? (
    <div>loading...</div>
  ) : (
    <FuxxkingUserProfile user={data} />
  );
}

具合が悪くなってきましたか?なってきたと思います。僕も書いていて手が震えてきました。
ただでさえ非同期処理で嫌な気持ちになっているところにさらに propsバケツリレー をさせられるわけです。
propsバケツリレーは データがどこから来ているか、誰がどのデータに依存しているかが分からなくなるため、以後の開発体験を急速に悪化させます。

とても人間的な所業とは思えません。

出来れば 同期処理と同じように 以下のような形で、 欲しいデータはコンポーネント自身で取得するように 書きたいと思いませんか?

function FxxkingUser() {
  const [data] = useSomethingAsyncStore(...);

  return <div>...</div>
}

見ているだけで吐き気を催す邪悪な先程のコードと比べ、こちらはいくらか見慣れた形になりました。

この形であれば先程の「非同期処理を意識した条件分岐」と「コンポーネントへデータをバケツリレーする」という邪悪な点を滅ぼすことが出来ます。

しかし、果たしてこんなことが可能なのでしょうか?

安心してください、こういった Render-as-You-Fetch パターンと呼ばれるスタイルを実現可能にするライブラリは意外とたくさんあります。やはり人類はみんな React でJSONに色付けさせられているんだなぁと思わざるを得ません。
そして、それらは React 本体の Suspense との組み合わせで絶大な効果を発揮します。

というわけで以上の点を前提として、この記事ではこの点をさらに深掘りしていきます。

<Suspense> と Recoil を使って非同期処理と和解せよ

本来であれば TanStack QueryuseSWR といったデータフェッチに適したライブラリを用いるのが適切ですが、本記事ではステップバイステップで学ぶことを目的としたいため、敢えて機能が薄めのライブラリを用いようと思います。

というわけでRecoil の紹介です。

https://recoiljs.org/

Recoil はいわゆる「状態管理ライブラリ」の一種ですが、非同期処理もある程度上手く扱える非常に柔軟なライブラリです。

また、このライブラリの特徴である「グローバルなデータを小さく扱うことが出来る」という点が、「コンポーネント自身で必要なデータを取得させる」という目的とよくマッチします。(こちらの使い方については後述します)

何にせよ、まずは recoil の仕組みに非同期処理を乗せるためのコードを示します。


const userSelector = selector<User>({
  key: "user/userSelector",
  get: async () => {
    const res = await fetch("...");
    return await res.json();
  }
});

selector は、値を加工して返す Store を定義するための機能です。
recoil 公式では、 selector を atom から取得したデータを何か加工して返す というだけの存在としてかなりぞんざいに扱われている印象がありますが、実は以下のように非同期処理を行って何らかのデータを fetch するような例が紹介されています。

https://recoiljs.org/docs/guides/asynchronous-data-queries/#asynchronous-example

要はこの機能を存分に使っていこうということですが、これは実はかなり強力な仕組みです。

いちおう軽く紹介ですが、この selector は以下のように使うことが出来ます。(詳細は上記公式ドキュメントを見ましょう)


function FxxkingUser() {
  const user = useSelector(userSelector);

  return <div>...</div>
}

このとき、 userSelecotr非同期セレクター として動作するため、非同期処理が解決されるまでの間このコンポーネントは Suspend (中断)されます。

なので、このコンポーネントは以下のように <Suspense> で囲い、レンダリングが 中断 された場合は fallback が表示されるようにします。

<Suspense fallback={<div>loading...</div>}>
  <FxxkingUser />
</Suspense>

しかし、このように書けたからといって根本的な解決には一見なっていませんし、ついでに言えばこれだけでは Render-as-You-Fetch パターンでもありません。
なぜなら、先ほど定義した userSelector は、結局 selector をただ単に 簡単に <Suspense /> が使えるラッパー として利用してデータを集約しただけに過ぎないからです。
これだけでは、結局ここで取得したデータを props で各コンポーネントに振り分ける羽目になってしまいます。

では、Render-as-You-Fetch パターンとは果たしてどのようなものか、また、このパターンに適したデータ設計とはどのような形になるのでしょうか。

Render-as-You-Fetch パターンでデータを分割統治せよ

分割統治という言葉でもしかしたらピンと来た人もいるかもしれませんが、重要なのは 表示すべきデータを最小限の単位に分割出来ないか ということです。

例えば User データを取得する際、エンドポイントから以下のようなごちゃまぜな JSON を取得する事が多いと思います。


{
  "userId": "xxxxxxxx-xxxxxxx-xxxx-xxxx",
  "profile": {
    "iconUrl": "path/to/cdn/xxxxxxxxxxxxx.png",
    "name": "xxxxxxxxxxxx",
    "description": "aaaaaaaaaaaaaaa" 
  },
  "posts": [{
    "postId": "yyyyyyy-yyyyyyy-yyyy-yyyy",
    "body": "welcome to heeeeeeeeeeeeeeeeeell"
  }],
  ...
}

バックエンド側で RDB のリレーションを辿って JOIN したりしながら素直に取得するとこういった形のデータになりがちです。

あなたがサバックエンドの設計にまで口を出せる立場であればエイヤと直してしまうのも良いかもしれませんが、そうではない可哀想な JSON 色付け戦士の皆さんのために、一旦このままステップバイステップでデータを Render-as-You-Fetch パターンに適した形に変形していくことにします。

とはいえ、それには一旦フロントエンドをどういう構造にするかを決めないと話が進まないので、ここでは以下のようなコンポーネントとしたいと思います。


// ユーザープロフィールを表示するコンポーネント
function UserProfile() {
  return <article>
    <section>
      <img src={data.iconUrl} />
      <span>
        {data.name}
      </span>
      <section>
        {data.description}
      </section>
    </section>
  </article>
}

// ユーザーの投稿したコンテンツを表示するコンポーネント
function UserPosts() {
  return <article>
    {data.map(v => (
      <article>
        {v.body}
      </article>
    ))}
    </article>
}


// 上記2つを集約するコンポーネント
function UserPage() {
  return <main>
      <div>
          <UserProfile />
      </div>
      <div>
          <UserPosts />
      </div>
    </main>
}

擬似コードなので未定義の変数があります。

あからさまに先ほど示したごちゃまぜJSONと構造が1対1なので、まあそりゃそうだろと思うかもしれませんがもう少しお付き合いください。

さて、従来までの考え方であれば、UserPage にドカンと JSON を取得する処理を書いて、それを props 経由で渡したりしていましたよね。

function UserPage() {
  const user = useRecoilValue(userSelector);
  return <main>
      <div>
          <UserProfile profile={user.profile} />
      </div>
      <div>
          <UserPosts posts={user.posts} />
      </div>
    </main>
}

しかし、 Render-as-You-Fetch パターンでは以下のようにします。


function UserProfile() {
  const data = useRecoilValue(userProfileSelector);
  return <article>
    <section>
      <img src={data.iconUrl} />
      <span>
        {data.name}
      </span>
      <section>
        {data.description}
      </section>
    </section>
  </article>
}

function UserPosts() {
  const data = useRecoilValue(userPostsSelector);
  return <article>
    {data.map(v => (
      <article>
        {v.body}
      </article>
    ))}
  </article>
}

よって、これらを集約するコンポーネントは元の見た目のままです。

function UserPage() {
  return <main>
      <div>
          <UserProfile />
      </div>
      <div>
          <UserPosts />
      </div>
    </main>
}

props 渡しのやり方に慣れていると「え!?子コンポーネントが外部のデータに依存していいの!?」となんとなく違和感を覚えるかもしれません。
しかし、これが Render-as-You-Fetch パターンなのです。

コンポーネントが欲しいデータはコンポーネント自身に取得させる という考え方の真髄は コンポーネントをデータの「流れ」の依存から断ち切る ことが出来るという利点にあります。

おっと、話を先に進める前に userProfileSelectoruserPostsSelector の定義も一応見ておきましょう。

const userProfileSelector = selector({
  key: "user/userProfileSelector",
  get: ({get}) => {
    const { profile } = get(userSelector);
    return profile;
  }
});

const userPostsSelector = selector({
  key: "user/userPostsSelector",
  get: ({get}) => {
    const { posts } = get(userSelector);
    return posts;
  }
});

selector は他の atom や selector からデータを取得できる、ということを知っている人であれば、この書き方は特に違和感のないものだと思います。
これは Recoil を使ってステート管理をするときはこのように なるべく小さい単位で扱う という原則を守るのが良いやり方であるという発想に基づくものですが、このやり方は Render-as-You-Fetch パターンと相性のよいものであることが分かるのではないでしょうか。

ちなみに、 userProfileSelectoruserPostsSelectorget: に渡されている関数が通常の関数であるため一見非同期セレクターではありませんが、 userSelector という非同期セレクターに依存しているため、 これらのセレクターも非同期セレクターとして扱われる ことには注意しましょう。

よって、コンポーネントも以下のように修正しておきます。

function UserPage() {
  return <main>
      <div>
        <Suspense fallback={<div>Loading...</div>}>
          <UserProfile />
        </Suspense>
      </div>
      <div>
        <Suspense fallback={<div>Loading...</div>}>
          <UserPosts />
        </Suspense>
      </div>
    </main>
}

このように <Suspense> で細かく囲うことで、非同期処理中に Suspend される範囲を最小限にすることが出来ます。(さもなければ、一番近くの親コンポーネントにある <Suspense> の範囲まで描画の中断が伝播し、画面が大きくガクガクする要因になることがあります)

※ ちなみに <Suspense> をどうしても使いたくない人向けに useRecoilValueLoadable というのもあります。

データをさらに細かく分割統治せよ

さて、ここまででかなりいい感じにコンポーネントを分割統治させることに成功しましたが、まだ甘いところがあります。
それは UserPosts コンポーネントです。

function UserPosts() {
  const data = useRecoilValue(userPostsSelector);
  return <article>
    {data.map(v => (
      <article>
        {v.body}
      </article>
    ))}
  </article>
}

ここで、例えば posts に入っているデータは「処理負荷等の都合で ID のみが入っており、詳細なデータは個別に取得する」というような設計であることもあり得るでしょう。

{
  ...,
  "posts": [
    {
      "postId": "yyyyyyy-yyyyyyy-yyyy-yyyy",
    },
    {
      "postId": "zzzzzzz-zzzzzzz-zzzz-zzzz",
    },
    {
      "postId": "vvvvvvv-vvvvvvv-vvvv-vvvv",
    },
    ...
  ],
}

とすると、こう書くしかなさそうですかね?

const userPostsSelector = selector({
  key: "user/userPostsSelector",
  get: ({get}) => {
    const { posts } = get(userSelector);

    const p = new URLSearchParams();
    posts.forEach(post => p.append("ids", post.postId));

    const res = await fetch(`/user/posts?${p.toString()}`);

    return await res.json();
  }
});

確かにこういう風に解決することはできそうです。
じゃあさらに「処理負荷の関係で posts は一度に全部取らせたくないので、続きが欲しかったら nextToken 突っ込んでページングして」と言われたらどうしますか?
まあ、とりあえず気合で頑張ってみるとこんな感じになるでしょうか。

const postsPagingInfoAtom = selector({
  key: "user/postsPagingInfoAtom",
  default: null
});

const userPostsSelector = selector({
  key: "user/userPostsSelector",
  get: ({get}) => {
    const { posts } = get(userSelector);
    const nextToken = get(postsPagingInfoAtom);

    const p = new URLSearchParams();
    posts.forEach(post => p.append("ids", post.postId));

    if (nextToken != null) {
      p.append("next", nextToken);
    }

    const res = await fetch(`/user/posts?ids=${p.toString()}`);
    // ちなみに、get の中で他の atom は更新 **出来ない** ので、ここで帰って来るであろう nextToken の取得と更新についてはちょっと工夫する必要がある
    return await res.json(); 
  }
});

さて、かなり 地獄 めいてきました。 JSON 色付け戦士はしめやかに爆発四散!
ちなみにこのコードだと以前取得した分は消失するので、ちゃんとキャッシュ的なのに突っ込んだりする必要があります。ウワアアアア!!!

しかも、コンポーネント本来の設計においてはそもそも UserPosts のようなリストの中に詳細に設計するのではなくて、投稿部分においてはさらに UserPost のような、投稿ひとつのみを表示するためのコンポーネントに分けるのがよさそうです。
しかし、ここまでで出てきた selector だけでは「情報の一部を受け取って何かを返す」という処理を実現するのは難しいです。

というわけで、そんなみなさんのために selectorFamily をご紹介します。


const userPostSelector = selectorFamily<UserPost, { postId: string }>({
  key: "user/userPostSelector",
  get: ({ postId }) => () => {
    const res = await fetch(`/user/posts?ids=${p.postId}`);
    return await res.json();
  }
});

selectorFamily とは、簡単に言えば Key-Value の形式を取ることが出来る selector です。
通常の atom/selector は単一の存在で、そこから取り出せるデータは常に1種類だけでした。

この atomFamily/selectorFamily はキーを取ることが出来、異なるキーを渡した場合は違うデータが格納/取得されるようにすることが出来ます。

この性質はまさに分割統治の考え方に最適です。
まだピンと来ないみなさんのために、実際に使用例を見ていきましょう。

type Props = {
  postId: string
}

function UserPost({ postId }: Props) {
  const data = useRecoilValue(userPostSelector({ postId }));
  return <article>
    <section>
      {data.body}
    </section>
    <section>
      {data.createdAt}
    </section>
  </article>
}

はい、こんな感じです。ピンと来ましたか? (来ない場合は公式ドキュメントを見ましょう。)

当然これは UserPosts ではこう使えます。

function UserPosts() {
  const data = useRecoilValue(userPostsSelector);
  return <article>
    {data.map(v => (
      <Suspense fallback="...">
        <UserPost key={v.postId} postId={v.postId} />
      </Suspense>
    ))}
  </article>
}

なんだかそれっぽくなってきましたね。
「あれあれ、 props 渡し使っちゃっていいの?」なんて意地悪を言う人はビンタします。

というわけで、これで posts の分割統治も完了したわけですが、何か忘れているような。

・・・

const userPostSelector = selectorFamily<UserPost, { postId: string }>({
  key: "user/userPostSelector",
  get: ({ postId }) => () => {
    const res = await fetch(`/user/posts?ids=${p.postId}`); // あれ?
    return await res.json();
  }
});

そういえばさっき作ったここ、もしかして UserPost 1個ごとにリクエスト飛んでね?

というわけで最後にもう一息です。

Dataloader で複数のリクエストを一本化せよ

最後はややパフォーマンスに関するお話です。

前項で UserPost 1つ1つを分割統治したまではいいのですが、このままでは UserPost を表示しようとした箇所の数だけリクエストが飛んでしまいます。

あなたの財布が無限にあれば無限にサーバーを強化すればいいのですが、大抵の場合そういうわけには行かないとは思うのでここは対策を考えましょう。

先ほどさらっと出てきたAPIは、 postId を複数個取ると、指定した postId の UserPost を取得してくる という地味に親切な設計でした。

// API仕様書のようなナニカ

GET /user/post
Query:
   `ids` string[]

Response:
{
  "posts": {
    "id1": {
      ...
    },
    "id2": {
      ...
    },
  }
}

であれば、複数同時に発生したAPIリクエストは1つにまとめたい というのが自然な欲求です。

というわけで、そういうのを実現してくれるライブラリを使いましょう。

https://github.com/graphql/dataloader

DataLoader です。

graphql ファミリーの一人らしいですが、実は単体でも使えます。
たぶん複数個の GraphQL クエリを投げつけるときに一つにまとめてくれる的な使い方が想定されているんだと思います(GraphQLで開発したことない(したいなあ))

で、実は GraphQL のリクエスト意外にも使えるように柔軟な設計になっており、以下の様に使うことが出来ます。

const userPostLoader = new Dataloader(async (ids: ReadonlyArray<string>) => {
    const p = new URLSearchParams();
    posts.forEach(post => p.append("ids", post.postId));
    const res = await fetch(`/user/posts?ids=${p.toString()}`);

    const resJson = await res.json();

    // DataLoader は所得したデータを ids の順番通りに並べる必要があり、存在しなかったデータは null を返す必要がある
    return ids.map(id => resJson.posts[id] ?? null);
});

中身はだいたい元々書こうとしていた userPostsSelector の中身にだいぶ近いものになっているので、さほど難しくはないと思います。

const userPostsSelector = selector({
  key: "user/userPostsSelector",
  get: ({get}) => {
    const { posts } = get(userSelector);
    // このへんから
    const p = new URLSearchParams();
    posts.forEach(post => p.append("ids", post.postId));
    const res = await fetch(`/user/posts?${p.toString()}`);
    // このへんまで

    return await res.json();
  }
});

そして、この userPostLoader を以下の様に使います。

const userPostSelector = selectorFamily<UserPost, { postId: string }>({
  key: "user/userPostSelector",
  get: ({ postId }) => () => {
    const res = await userPostLoader.load(postId);
    return await res.json();
  }
});

こうすることで、一度に発生したリクエストを一定時間ごとにまとめてくれるようになりました、すごい!!!(実際どういう動きをするのかは、まとめの最後に置いてあるリポジトリと、そのアプリを動かしているページをご覧ください)

このような仕組みを適用することで、このコンポーネントを表示するタイミングでコンポーネント自身が表示のために必要なデータを自ら取得するという動きを比較的簡単に実装することが出来ます。やったね。

まとめ

本記事では、 Recoil + Suspense + DataLoader を使って、React における分割統治方法の実現手法をステップバイステップで学んできました。
これまで React 上で WebAPI を扱うのがなんとなく苦手だったり書くのがつらすぎるからという理由で敬遠していた方の頭の中で何かしらの方針が得られたことを願っております。

React (引いてはウェブフロントエンド領域)の話からはやや逸れるため本記事ではバックエンド側の話は極力省きましたが、 Render-as-You-Fetch パターンを前提としたコンポーネント設計を行う上では、先ほど出てきたような「指定した ID を持つユーザーの詳細情報を Key-Value 形式で返してくれる API」のような設計が必要になってきます。(実装自体は、例えば MySQL ならば WHERE IN といったクエリを用いるなどすればそこまで大変な話ではありません)

特に最近のウェブフロントエンドは Next.js の台頭や Server Actions の登場などもあり、これまでのフロントエンドとバックエンドは独立性を高めるように進化してきた流れから再び逆行し、 フロントエンドとバックエンドの距離がますます近くなる ことが予想されます。

これは フルスタックエンジニアになれ ということだけを言っているのではなくて、これからは バックエンド側もフロントエンド側の設計を汲んで作る必要があるし、フロントエンド側もバックエンド側にあまりフロントエンドの都合を押し付けないようなコンポーネント設計を心がけましょう という、言ってしまえばごく当たり前のことを言っています。

なお、冒頭の繰り返しになりますが今回紹介した Recoil + DataLoader は、 Render-as-You-Fetch パターンを実現する理想のライブラリ ではありません。

Recoil 単体では selector の値を invalidate するのがやや面倒な点があるため、実際に用いる場合はやはり TanStack QueryuseSWR あたりを使うのを再度おすすめしておきます。

最後に 実際に今回の記事で出てきたようなパターンを適用したサンプルアプリ を置いておきます。

ぜひソースコードを見たり、アプリを触ったり F12 開発者ツールを押して、フロントエンドからのリクエストが実際にまとめられている様子を観察してみてください。

https://github.com/hapo31/react-divide-demo
(なお余談ですが、本記事は上記リポジトリのREADME内に書いた身内向けアドカレの内容微修正版です)

https://react-divide-demo.vercel.app/

それではよき React ライフを👋

Discussion