mytweetsという自分の Twitter/Bluesky の自己ポストの全部検索サービスをNext.js App Router(RSC)で書きなおしました。

mytweets は Twitter のアーカイブや Bluesky の API を使って自分のポストを S3 に保存しておき、 S3 Selectを使って全文検索ができる自分専用の Twilog のようなサービスです。

最初は CloudFront + Lambda@Edge + Next.js Pages Router で動かしていました。 その後、Next.js App Router が Stable になったので、App Router + React Server Components(RSC)で書きなおしました。

この記事では、Next.js Pages Router から Next.js App Router(RSC)に書きなおした話を紹介します。 ただし、この記事は発散的な内容になっているのと、あまり正確性が保証されてないので、個人的なメモ書きとして読んでください。

あまりにも長くなったので、あんまり読みやすくは書けませんでした。

📝 キャッシュとメモ化については特に触れていません

mytweets の動作

mytweets は、次のような動作をします。

  • クエリがない場合は、S3 Select から最新のポストを取得して表示
  • クエリを入力したら、S3 Select の API を使って全文検索を行い、結果を表示

この動画は、App Router + React Server Components(RSC)で動かしてるものを録画したものです。 表示的にファーストビューが出てからローディングが走って、結果を取得してポストを表示するという動作をしているので一般的なSPA (Single-page application)っぽく見えます。

実際のコードベース上では、クライアント側には Fetch API などは書いていません。 初期化のロード表示は、RSC + <Suspense> + useで実現しています。(静的な部分は SSR されているので、TTFB(Time to First Byte)が短いです。) 検索時の更新のロード表示は、Next.js のrouter.pushuseTransitionで実現しています。

App Router への移行のメモ

どのように移行したかを簡単に振り返ってみます。 メモ書きのようなものなので、かなり乱雑に書かれています。 具体的な変更だけ見たい人は、次の Pull Request を見てください。

大きく 3 つのステップで移行しました。

  1. App Router に移行
  2. RSC を使うように変更
  3. Suspense を使うように変更

1. App Router に移行

元々 mytweets は Next.js Pages Router で動いていました。

サーバ側の処理は、API Routesで S3 Select を叩く API を用意してるぐらいで、他はほぼクライアントの処理でした。 次のindex.tsxという一つのファイルに全部書いてあるような単純なページでした。

そのため、このindex.tsxに”use client”をつけて Client Component として移行すれば App Router でも動きます。

/pages/apiに定義するAPI Routesは、App Router でも動くのでサーバ側の処理はそのままにindex.tsxpages/からapp/に移動して、use clientをつけた Client Component に変更しました。

これで一旦 App Router で動くようになりました。 特に App Router の機能は使ってないですが、段階的に移行する際にはこのようなアプローチが利用できます。

参考:

2. RSC を使うように変更

このままでは、App Router の機能を使っていないので、RSC を使うように変更しました。 RSC をちゃんと使うために、コンポーネントが Client Component なのか RSC なのかが明確になっている必要があります。

これは、Client Component は RSC をインポートできないが、RSC は Client Component をインポートできるという不可逆性があるためです。 そのため、コンポーネントの境界を明確にする必要があります。

子\親 Client RSC Server Action
Client インポートできる インポートできない 呼べる(通信が発生)
RSC インポートできる インポートできる 呼べる(関数コール)

RSC はuseStateuseEffectなどは使えません。 インタラクティブな部分は、Client Component で行い、RSC はデータを受け取って表示するという形になります。 RSC は、サーバ側で処理されるので、そこで moment や marked のようなライブラリを使っても、クライアント側にはライブラリは含まれません。 (あくまで、処理結果だけがクライアントに渡される)

この境界を見極めるのが結構難しいですが、最悪 Client Component のままでも動作的には問題ないです。 そのため、mytweets で RSC を使う部分は、次のような目的を設定して進めていきました。

  • 基本コンセプトはクライアントサイドのサイズを削る目的

Client Component と RSC がツリーに混在することはありますが、基本的にはどちらかが上にいる形になります。

Client Component で RSC を包むような形は、Composition Patternsを使うとかけます。

"use client";
// children(RSCもOK) として ReactNode を受け取る
export const ClientComponent({ children }: ReactNode) {
  return <div>{children}</div>;
}

この書き方のユースケースとしては、枠を Client Component で作って、その中に RSC を入れてロード中は opacity を下げるというような使い方ができます。

mytweets でも入力して検索中の opacity を下げることでロード中を表現しています。

// Composition Patternを使う
// https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns
<ClientComponent>
  <RSC />
</ClientComponent>

逆に RSC の中に Client Component を入れるには単純にインポートして使うだけです。

多くの場合は、この形になって大枠を RSC に書いていき、部分的に Client Component を使うという形になります。 原理的にはIslands Architectureと同じで、大枠は静的な表示(RSC)にして、インタラクティブな部分(Client Component)を小さくしていくという形になります。

// RSCはClient Componentをインポートできる
<RSC>
  <ClientComponent />
</RSC>

Client Component と RSC のコンポーネントが横並びになる場合がかなり難しいです。

基本的には RSC をツリーの上に持ってきて、Client Compoent はツリーの下に持ってくると書きやすいと思います。 これ言い換えると、インタラクションがあるボタンやフォームなどは、範囲を限定しておくという形になります。 (再描画の範囲も小さくなるように書くというのが意識としては近いと思います)

// RSCが上にある形は、RSCからClient Componentをインポートできるので問題ない
<RSC>
  <ClientComponentX />
  <RSC_Y />
  <ClientComponentZ />
</RSC>

次の Client Component が上にある混在の仕方はかなり難しいので、基本的に避けた方が良さそうです。

// Composition Patternもやりにくいので基本的に避けたい
<ClientComponent>
  <RSC_Y />
  <ClientComponentX />
  <RSC_K />
</ClientComponent>

この辺を考えながえら、クライアントに不要なものを RSC に移行していくと、 最終的には、useStateuseEffectが必要ない部分が全部 RSC になりました。

擬似的なアプリの構造は、次のようになりました。

export const App = ({ searchParams }) => {
    const searchResults = await fetchS3Select(searchParams.query);
    return <TransitionContextProvider> {/* Client */}
        <SearchBox />  {/* Client */}
        <SearchResultContentWrapper>  {/* Client */}
                <SearchResultContent searchResults={searchResults} />  {/* Server */}
        </SearchResultContentWrapper>
    </TransitionContextProvider>
};

これに合わせて、データのリロード方法も RSC ベースに変更しています。 RSC はルーティングを移動すれば、もう一度 RSC の処理がよばれるので、ルーティングを移動することでデータのリロードができます。

内容 変更前 変更後
ベースの仕組み Pages Router + api/ App Router
API サーバ api/ で Rest API を作り、クライントから呼び出す RSC から関数としてサーバの処理を書いて呼ぶだけ
S3 Select の取得 api/ で S3 Select を叩いて、Stream として返して、クライアントから Fetch with Stream で取得しながら表示 pages.tsx で、S3 Select から取得して props で各コンポーネントに配るだけ
更新処理 入力欄が変更されたら、useEffect で Fetch して取得 → State を更新して描画し直す 入力欄が変更されたら、 router.push("/?q={検索]") へ移動するだけ(取得は pages.tsx に書かれてる仕組みがそのまま使われる)
初期ロード中の表示 Client 側で取得する。取得中は、isLoading の state(useState)を更新して、取得が終わったら state を更新する pages.tsx で、S3 Select から取得してし終わったらレンダリングするので、初期ロードはなし(ただし、S3 から取得できるまでページが表示されない)
更新中の表示 (初期ロードと同じ) Client 側で取得する。取得中は、isLoading の state(useState)を更新して、取得が終わったら state を更新する startTransition(() => router.push(...)) で更新中かの状態(state)を得て、更新中の表示を行う。この state を Context を通して、Client Component 間で共有して、いろいろな場所のローディング表示を行う。
URL 特に変化しない 入力に合わせて ?q=<クエリ> を更新していく

実際の Pull Request は、次の URL から見れます。

この時点でパフォーマンスは良くなり、クライアントからもuseEffectと Fetch でデータ取得をしていた複雑な部分がなくなりました。 コンポーネントの境界を考えたり、RSC と Client Component の組み合わせのためのコンポネーントは増えたりしますが、ロジック自体はかなりシンプルになりました。

  • FCP: 0.6s → 0.3s
  • LCP: 0.6s → 0.3s
  • Speed Index: 1.8s → 0.5s

perf App Router

3. Suspense を使うように変更

ここまでで、App Router + RSC で動くようになりました。 一方で、S3 Select の検索が終わるまで、ページが表示されないという問題があります。 S3 Select はファイルの上から下まで全文検索するので、ヒットしない場合は時間がかかります。

その間、ページが表示されないのは体験として良くないので、検索中もページが表示されるようにするために、Suspense を使うように変更しました。 Next.js のドキュメントだと Streaming という言葉が使われていますが、React の Suspense でロード中はプレースホルダーを表示する仕組みのことです。 (Fetch with Stream とは異なるものです)

RSC は、props として Promise を渡せるようになっています。

そのため、コンポーネントの Props として Promise を受け取り、そのコンポーネントを Suspense でラップすることで、ローディング中の表示を行うことができます。 受け取った Promise を unwrap するには、useを利用します。

先ほどのコードでは、awaitしていたので検索が終わるまでページが表示されない形になっていました。 次のようにawaitを外して Promise として、その Promise を検索結果を表示するコンポーネントに props として渡すだけです。

- const searchResults = await fetchS3Select(searchParams.query);
+ const searchResultsPromise = fetchS3Select(searchParams.query);

今までのコンポーネントの中でuseを使って Promise を unwrap(resolve した値を取得)してもいいのですが、promise を受け取るコンポーネントが気持ち悪いので、それ用のラッパーコンポーネントを定義しました。

// useでunwrapして渡すだけのコンポーネント
export const SearchResultContentStream = (props: {
  retPromise: Promise<FetchS3SelectResult>;
  screenName: string;
}) => {
  const { retPromise, ...other } = props;
  const ret = use(retPromise);
  return <SearchResultContent ret={ret} {...other} />;
};

もしくは、サーバ側のみで動くコンポーネントなら、async/awaitが利用できるので次のように書いても良いかもしれません。

export const SearchResultContentStream = async (props: {
  retPromise: Promise<FetchS3SelectResult>;
  screenName: string;
}) => {
  const ret = await props.retPromise;
  return <SearchResultContent ret={ret} {...other} />;
};

この Suspense と use を使ったラッパーコンポーネントを使った擬似的なアプリの構造は、次のようになりました。

export const App = ({ searchParams }) => {
  const searchResults = fetchS3Select(query); // waitしないでpromiseのまま扱う
  {/* Client */}
  return <TransitionContextProvider>
    {/* Client */}
    <SearchBox />
    <SearchResultContentWrapper>
      {/* Client */}
      <Suspense fallback={"Loading ..."}>
        {/* Server 中で use を使う*/}
        <SearchResultContentStream retPromise={searchResults} />
      </Suspense>
    </SearchResultContentWrapper>
  </TransitionContextProvider>;
};

これで、検索中もページが表示されるようになりました。 この変更により、検索が遅い場合でもページ自体は安定してすぐに表示されるようになりました。

📝 最良パターンは2より若干悪くなる(CLSもあるため)。最悪パターンは2よりかなり改善される

内容 変更前 変更後
ベースの仕組み Pages Router + api/ App Router
APIサーバ api/ でRest APIを作り、クライントから呼び出す RSCから関数としてサーバの処理を書いて呼ぶだけ
S3 Selectの取得 api/ でS3 Selectを叩いて、Streamとして返して、クライアントからFetch with Streamで取得しながら表示 pages.tsx で、S3 Selectから取得してpropsで各コンポーネントに配るだけ
更新処理 入力欄が変更されたら、useEffectでFetchして取得 → Stateを更新して描画し直す 入力欄が変更されたら、 router.push(”/?q={検索]”) へ移動するだけ(取得は pages.tsx に書かれてる仕組みがそのまま使われる)
初期ロード中の表示 Client側で取得する。取得中は、isLoadingのstate(useState)を更新して、取得が終わったらstateを更新する 更新点: <Suspense> を使う。<Suspense> と use を使うことで、部分的にローディング表示を組み込む。S3からの取得が完了する前からページは表示される。
更新中の表示 (初期ロードと同じ) Client側で取得する。取得中は、isLoadingのstate(useState)を更新して、取得が終わったらstateを更新する startTransition(() => router.push(...)) で更新中かの状態(state)を得て、更新中の表示を行う。このstateを Contextを通して、Client Component間で共有して、いろいろな場所のローディング表示を行う。
URL 特に変化しない 入力に合わせて ?q=<クエリ> を更新していく

実際の Pull Request は、次の URL から見れます。

感想

よく作られたサイトは クライアントサイドレンダリング だけでもほぼ RSC と同じことはできるけど、RSC はコンポーネントを分割する一種の規約なのでそれが強制される。 これは言い換えると、サイトが複雑化してきたときでもパフォーマンスが急激には悪くなりにくいという形になってる。 一般的に、クライアントサイドレンダリングだけだと開発が進んで複雑化してきたときに、同じパフォーマンスを維持するのはかなり難しくなる。

具体的には API が増えたときにどうするか、コンポーネントが増えた時にここは遅延ロードしないといけないとか、細かいことを色々考える必要がある。 これまでは GraphQL で必要なものだけを取得したり、初期表示に必要ないコンポーネントを遅延ロードするなどの対応をしてきている。

RSC だと

  • 必要なものだけ読み込む: RSC は必要なものだけを持ったコンポーネントをシリアライズしてクライアントに渡す仕組みになってる
  • 遅延ロード: Suspense を使ってStreamingでコンポーネントをロードする

パフォーマンスの悪化を避ける方法として、いらない処理を別のところに逃すというのは良くあることで、 RSC だと、この逃す場所として RSC と Server Action が増えたという感じがする。 必要になるまで読み込まないという考え方をなんでも取り込んでるのはQwikで、RSC の場合はシリアライズできる範囲としてコンポーネントと Promise ぐらいになっている。

一方で、Server Action は何も規約がないので、Web API を作る意識なくやってしまうと無法地帯となる可能性がある。 これは Next.js が柔軟性のためにフレームワークをしてない部分なので、この辺はもうちょっとフレームワークとしての仕組みが必要そう。

Next.js の App Router を見たときに、Client と Server で話を分けたくなるけど、実際には React Client Components/React Server Components/React Server Actions の 3 つに分かれる。

Why do Client Components get SSR’d to HTML? · reactwg/server-components · Discussion #4を見ると、クライアントとサーバというのは物理的なクライアントサーバの話ではないのがわかる。 React の元々あった Tree のことを Client Tree と呼んで、React Server Component と一緒にできたのを Server Tree と呼んでる。 (HTML を生成するものを”Client”と呼んで、“Client”にシリアライズしたデータを処理して渡すやつを”Server”と呼んでるだけ) そのため、“React の Client Component”ではなく”React Client の Component”という感じの意味合いになってる。

また、Client Component は RSC をインポートできないというルールを思い出すと、それぞれが扱うデータの範囲が異なるという感じがする。

名前 ユーザー入力 サーバーデータ
React Client Components 受け取る indirect read-only
React Server Components 受け取らない direct read-only
React Server Actions 受け取る direct read/write

📝 React Server Componets は searchParams でユーザー入力は受け取れるので全部ではない。またサーバーデータも読み書きできてしまうが、GET で Write は基本避けるので原則的な話。

RSC から Server Actions を呼ぶこともできるけど、その Server Actions を Cient Component から使い回すというやり方をすると事故る可能性がある。 これは、RSC がユーザー入力を基本的には受け取らない(searchParams はあるけど)けど、Client Component は受け取るという違いがある。 Server Actions から見るとどちらも同じ引数として渡ってくるので、この引数が安全なのかは基本的にわからない。

Server Actions は、クライアントとのインタラクティブ性がある API だったり、ユーザーに紐づかないデータ処理をサーバに逃すのに適している。 たとえば、郵便番号の検索して住所を返す処理とかフォームのバリデーションのような処理。

一方で、データを実際に Write するような処理は気をつけないといけないので、その辺はRoute Handlersの方が API として扱うには安全な感じがする。 もしくは、Server Actions で一旦受けてから、別のサーバの関数にバリデーションしてから渡すような形にするとか。 この辺が、結構あいまいになりやすい気がするので、ここはもうちょっと整理されるといいと思いました。

App Router は全体的に、需要を満たすための柔軟な機能を多めに入れている感じはします。 Page Router の場合は、最初はそこまでなんでもできるというものじゃなかった気はしますが、App Router は最初から Pages Router の superset として作られている感じはします。おそらくここが、複雑に感じる部分で、この辺が整理されるともっと使いやすくなると思う。

何が opt-in で何が opt-out なのかがわかりにくいのも、難しく感じる部分なのかもしれません。

opt-in opt-out
App Router キャッシュ
React Client Component(”use client”)
React Server Action(”use server”)

これは適当なテーブルなのでどこかにドキュメントが欲しい。

感想のサマリ

  • Next.js App Routerをちゃんと使うとパフォーマンスが落ちにくいサイトを作れるフレームワークになっている
  • 一方で、ただ乗れば作れるという感じではなく、ちゃんと設計する必要はある
  • 現状だと、フレームワークがフレームワークしてない部分もあるので、この辺は考えて扱う必要がある

こっからはメモ書き成分が多いです。 作りながら書いてたメモをコピペしてます。

技術的なメモ書き

実際に動かさないとやり方がわからなかった部分をメモ書きとして残しておきます。

Client Component 間のデータのやり取り

Islands Architectureと同じ話ですが、Client ComponentとRSCの境界を切っていくと、Client Component同士が離れた位置にあるけど、状態は同期したいというケースが出てきます。 入力中の表示を別の場所に出すとか、ロード中は色々なところにあるボタンをdisabledにしたとか、大枠をまたいで状態を共有したいというケースです。

この場合は、Client Component間で状態を共有する方法が必要です。

やったこと

具体的には次のようなTransitionContextProviderという Provider のラッパーコンポーネントを用意してる。 このコンポーネントは RSC からもインポートして埋め込むことができる。

"use client";
import { createContext, ReactNode, useContext, useState } from "react";

export type TransitionContext = {
  isLoadingTimeline: boolean;
  setIsLoadingTimeline: (isLoading: boolean) => void;
};
const TransitionContext = createContext<TransitionContext>({
  isLoadingTimeline: false,
  setIsLoadingTimeline: () => {},
});
export const TransitionContextProvider = (props: { children: ReactNode }) => {
  const [isLoadingTimeline, setIsLoadingTimeline] = useState(false);
  return (
    <TransitionContext.Provider
      value=
    >
      {props.children}
    </TransitionContext.Provider>
  );
};
export const useTransitionContext = () => {
  const context = useContext(TransitionContext);
  if (!context) {
    throw new Error(
      "useTransitionContext must be used within a TransitionContextProvider"
    );
  }
  return context;
};
  • Server では引数に setState を渡すということができないので、初期値を持たない Context Provider を作るにはラッパーが必要となる
  • RSC では、Client Component の境界のためにこういったラッパーコンポーネントを作るケースが結構ある
  • ここではContextを作っているけど、state管理のライブラリを使う場合も大体似た話になります

参考

ルーティングの移動中の判定

router.pushで移動中の表示をしたいというケース。 たとえば、移動中はローディング表示をしたいとか、ボタンクリックでロード中はボタンを disable にしたいというケース。

  • useTransition を使うとできる
  • router.push と const [isPending, startTransition] = useTransition(); を組み合わせる
// 移動中はisLoadingがtrueになる
const [isLoading, startTransition] = useTransition();
const handlers = useMemo(
  () => ({
    search: (query: string) => {
      startTransition(() => router.push(`/?q=${query}`));
    },
  }),
  []
);

これは、Server Action を呼ぶときにも利用できる。

この辺が、はっきりと Next.js のドキュメントには書かれてなくてかなりわかりにくいと思った。

問題

フォーカス管理とルーティング

  • input の状態とルーティングと同期できていない
    • 戻るで戻ったときにinputの値が残ったままになる
  • Vercel の公式サンプルもルーティングと input の同期するために <input key={key}/>という感じで key を変えて破棄している
  • key で破棄すると input のフォーカスも無くなるので、体験が悪い

Failed to load response data: No data found for resource with given identifier

参考