🎆

Next13 動的クエリ、 Server Component で実装するか?Client Component で実装するか?

2023/10/26に公開

App Router が stable に 🎉

Next 13.4 で App Router が stable になりServer Componentの本番環境での使用が現実的になりました。
しかし Server Component には様々な制限があり、既存のプロジェクトをApp Router で動かすことができるのか、そして App router 移行で恩恵が受けられるのかなど、気になる点は色々ありました。
そこでApp Router を色々触っていたのですが、その中で最も移行の際に既存実装から変更が必要だと感じた、動的クエリの実装について深掘りしていきたいと思います。

動的クエリとは 🤔

本記事では動的クエリを「ユーザーのアクションによって毎回パラメータが変わるクエリ」とします。
簡単な動的クエリの例として以下のような簡単な図書検索画面を考えます。
検索ボックスに図書のタイトルを入力して検索ボタンを押すと、Data source からタイトルが部分一致する図書のリストを取得して表示します。

こちらでデモを公開しています。
https://dynamic-query-rsc.vercel.app/

※Data source には CiNii Books 図書・雑誌検索APIを使用しています。

この検索画面を検索フォームコンポーネント (SearchForm.tsx) と検索結果リストコンポーネント(SearchResultList.tsx)の2つに分けて実装しようと思います。

まず初めにそれぞれ SC, CC どちらで実装するか考えます。
SearchFormはユーザーからのインタラクティブな入力が必要なため、CCで実装する必要があります。SearchResultListの方はどうでしょうか?このコンポーネントはAPIから取得したデータを表示します。
SCを使うか、CCを使うかの判断基準はNext ドキュメントにまとめてあり、一般的にFetchを用いる場合、SCが良いとされています。なので一旦検索結果リストコンポーネントはSCで実装してみましょう。

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns

実装 Server Component 🌟

以下のように検索結果リストコンポーネントをSCで実装しました。
検索文字列のtitleをPropsで受け取って、リクエストパラメータとしてfetchします。
fetchで取得したデータを加工してclientに返します。

SearchResultList.tsx
type Props = {
  title?: string;
};

export const SearchResultList = async ({ title }: Props) => {
  if (!title) {
    return <div></div>;
  }
  const data = await (
    await fetch(
      `https://ci.nii.ac.jp/books/opensearch/search?title=${title}&format=json`
    )
  ).json();
  const books: any[] = data["@graph"][0].items;

  return (
    <>
      {books.map((book) => {
        return (
          <div key={book["@id"]}>
            <div className="text-lg">📗 {book["title"]}</div>
            Author: {book["dc:creator"]}
          </div>
        );
      })}
    </>
  );
};

SC を使用するとfetch処理を非常にシンプルに書くことができます。コンポーネント内でawaitが使えるのも嬉しいですね。さらに、ユーザイベントやhooksがなく、基本的にコードは上から順に実行されるだけなので、処理が非常に追いやすいです。

続いて、検索フォームをCCで実装してきましょう。こちらは一般的なReactのフォームですね。
App Router ではコンポーネントはデフォルトではSCになってしまうので、ファイルの先頭に "use client" と書くことでCCとすることができます。

SearchForm.ts
"use client";
import React, { useState } from "react";

export const SearchForm = () => {
  const [title, setTitle] = useState("");
  
  const onSearch = () => {
    // 検索ボタンが押されたときの処理
  };

  return (
    <>
      <input onChange={(e) => setTitle(e.target.value)}/>
      <button
        onClick={() => {onSearch();}}>
        Search
      </button>
    </>
  );
};

どうやってCCのstateをSCに渡すか 🤔

検索フォーム(CC)、検索結果リスト(SC)をそれぞれ実装することができましたが、ここで1つ問題があります。どうやって検索フォーム(CC)のstateを検索結果リスト(SC)に渡すことができるのでしょうか?
両方ともCCであれば話は単純でpropsなどを使ってstateを共有することができます。しかし、CCとSCではpropsによるstateの共有ができません。そもそもSCはサーバーで実行されるので何らかの方法でサーバーにstateを送信しない限り、CCのstateをSCに共有することはできません
クライアントからサーバーにstateを送信する方法はいくつかありますが、ここでは簡単に実装できるクエリパラメータを使用する方法cookieを使用する方法を紹介します。

クエリパラメータを使用する方法 🚀

next/navigation の router で navigation するときにCCのstateをクエリパラメータとしてサーバーに送信する実装です。router.replaceでURLにクエリパラメータを付与することができます。

SearchForm.ts
"use client";
import React, { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";

export const SearchForm = () => {
  const [title, setTitle] = useState("");
  
  const onSearch = () => {
    const searchParams = useSearchParams();
    const params = new URLSearchParams(searchParams);
    params.set("title", title);
    router.replace(`/query-parameter?${params.toString()}`);
  };

  return (
    <>
      <input onChange={(e) => setTitle(e.target.value)}/>
      <button
        onClick={() => {onSearch();}}>
        Search
      </button>
    </>
  );
};

続いて、SCの実装です。App Router の page コンポーネントではpropsとしてクエリパラメータを受け取ることができます。ここで受け取った値をSearchResultListへpropsで渡しています。SCのSearchResultListは再実行されてre-fetchが走り、データを更新してくれます。

page.ts
export default function Page({
  searchParams,
}: {
  searchParams: { [key: string]: string | undefined };
}) {
  const title = searchParams["title"];

  return (
    <>
      <SearchResultList title={title} />
    </>
  );
}

Cookieを使用する方法 🍪

CCでCookieにstateをセットしてSCとstateを共有する方法です。Cookieをセットした後、router.refresh()を実行します。
router.refresh()を実行すると、Router cacheをクリアした上でサーバーにリクエストを送信してくれます。

SearchForm.ts
"use client";
import { SearchForm } from "@/app/_components/SearchForm";
import { useRouter } from "next/navigation";

export const SearchFormCookie = () => {
  const router = useRouter();
  const onSearch = (title: string) => {
    document.cookie = `title=${encodeURIComponent(title)}`;
    router.refresh();
  };

  return <SearchForm onSearch={() => onSearch()} />;
};

続いて、SCの実装です。Nextではnext/headersからcookieを取得することができます。
以下のSCでは取得したcookieからCCが送ってきた値を取り出してSearchResultListにpropsとして渡します。

page.ts
import { cookies } from "next/headers";
import { SearchResultList } from "../_components/SearchResultList";

export default function Page() {
  const cookieStore = cookies();
  const title = decodeURIComponent(cookieStore.get("title")?.value ?? "");

  return (
    <>
      <SearchResultList title={title} />
    </>
  );
}

クエリパラメータ VS Cookie ⚖️

さて2つの方法を紹介しましたが、それぞれのメリットを考えてみましょう。
クエリパラメータを使用する場合、状態をURLで管理していると言えます。この場合URLを共有すれば、同じ画面を再現することができます。なので、URLによる共有を実現したい時はクエリパラメータを用いた状態管理は良い方法です。逆に、URLに状態を持たせたくない場合や値がオブジェクトでクエリパラメータにエンコードするとURLが異常に長くなってしまう場合はcookieを用いるのがいいと思います。

両者の懸念点 😣

2つの方法を紹介しましたが、それぞれの方法には共通の懸念点があります。

CC の state が SC に渡る処理が追いづらい

クエリパラメータ、cookieともにサーバーにリクエストを送ってstateを送信しています。これはCC間のstate共有と比べると複雑です。特にCCで送信したstateがどこで使われるのか分かりづらいです。やりたいことはコンポーネント間でのstateの共有だけなので、クエリパラメータやcookieを使用するのはちょっと大げさな気がします。

同じ page のすべてのコンポーネントが再実行されてしまう

クエリパラメータやcookieを送信すると、リクエストパスのページ配下のコンポーネントがすべて再実行されてしまします。ただし、リクエストパラメータの変化のないfetchの結果はcacheされているので、そこまでこの再実行が問題になることはないのかもしれません。また、この問題はuseTransition()を使うことで回避できます。useTransiton()を使ってコンポーネントの再実行を優先度の低いタスクに変えることによって重いコンポーネントに画面がロックされる問題を回避することができます。React公式のServer Component デモ実装でもCCからSCにstateを共有する箇所をuseTransition()でラップしていました。

https://github.com/reactjs/server-components-demo/blob/95fcac10102d20722af60506af3b785b557c5fd7/src/SearchField.js#L29-L37

Client Componentで実装すればいいのでは? 💡

ここまで2つのCC、SCでのstateの共有方法とその懸念点を紹介してきました。懸念点を考えると、無理に検索結果表示部分をSCで実装せずにCCで実装すればいい気がしてきます。ここで再びスタート地点に戻って、SCを使うメリットを考えてみましょう。Nextの公式ドキュメントにはSCのメリットとして以下が挙げられています。

データ取得: データ取得をデータソースに近いサーバーにで実行することで、レンダリングに必要なデータを取得する時間と、クライアントが行うリクエストの数を削減して、パフォーマンスが向上することがある。

セキュリティ: トークンやAPIキーのような機密データやロジックをサーバー上に保持しつつ、クライアントにそれらを公開するリスクを回避できる。

キャッシング: 結果をキャッシュして、後続のリクエストやユーザー間で再利用することができる。

バンドルサイズ: 処理をサーバーに移すことでクライアントのJSを削減できる。

初回ページロード: サーバー上で、HTMLを生成して、ユーザはクライアントがページをレンダリングするために必要なJavaScriptをダウンロード、解析、実行するのを待たずにページを表示できる。

検索エンジン最適化とソーシャルネットワークの共有性: 検索エンジンボットがページをインデックスしたり、ソーシャルネットワークボットがページのソーシャルカードのプレビューを生成するために使用することができる。

ストリーミング: サーバーコンポーネントは、レンダリング作業をチャンクに分割し、それらを準備ができたときにクライアントにストリームすることができる。

https://nextjs.org/docs/app/building-your-application/rendering/server-components

それぞれのメリットについて今回の動的クエリがメリットを享受できるか考えてみる 🤔

データ取得
今回の例だとユーザが検索ボタンを押すたびにfetchリクエストが走ることは避けられないので、クライアントが行うリクエストの数を削減するSCのメリットは受けられない。
しかし、data source から取得するデータに表示に不要なデータが多く含まれていて、SC内でそのデータを削減してクライアントに送る場合、パフォーマンスが向上する可能性がある。
セキュリティ
APIキーをサーバー上に保持するメリットは今回の例でも嬉しい。
キャッシング
今回のような毎回リクエスト内容が変わるものはキャッシュを活用できない。
バンドルサイズ
バンドルサイズが小さくなるのは嬉しい。
初回ページロード
今回の例だと初回のロードはあまり関係ない。
検索エンジン最適化とソーシャルネットワークの共有性
検索結果を表示する例ではSEOなどは関係ない。
ストリーミング
基本的に初期ロードのための機能なので今回のような再レンダリングではあまり関係ない。

総じて、セキュリティやバンドルサイズのメリットはあるが(状況によってはfetchのメリットも)、それ以外のSCのメリットは動的クエリではあまりないことが分かります。

結局どっちを使う?

個人的には、今回のような動的クエリの実装にはCCが適していると考えます。その理由として、SCの長所を十分に活かせない上、CCとSCのstate共有がやや複雑であることが挙げられます。CCの場合、よりシンプルにpropsを使って値の共有が可能です。ただし、どちらを採用するかはプロダクトの仕様や採用しているアーキテクチャに応じて異なると思います。両方のメリット・デメリットを検討した上で、最も適した方法を選択することが重要だと感じました。この記事がその判断の手助けになれば幸いです。

URLパラメータを使いたい時はSCがいいかもしれない

動的クエリの実装はCCで実装するのがいいと言いましたが、検索の仕様としてURLパラメータにパラメータを埋め込みたい時はSCを積極的に使ったほうがいいかもしれません。
というのも、URLパラメータでstateを管理する場合、SCを使わなくてもstateの共有の難しさ(どこでURLにセットしてどこでそれを受け取るのかが不明瞭)は避けられないからです。

おわりに 🎆

本記事では図書検索画面の動的クエリを例にSC,CCどちらで実装するのがいいのかを検討してきました。色々調べていて思ったのはSCの実装周りはまだベストプラクティスが固まっていないということです。CCとSCでstateを共有する方法もそこまで情報があるわけではなく、みんな手探り状態で色々試している段階という印象です。今後、SCがもっと使わていくようになって、ここらへんのベストプラクティスが定まっていくと思います。

最後までお読みいただき、ありがとうございました!

本記事のデモ用で実装したコードはGitHubにアップしています。
https://github.com/kult0922/dynamic-query-rsc

AI Shift Tech Blog

Discussion