🎉

[Next.js]極限まで簡単に非同期データのSSRを実装する

2023/02/27に公開

極限まで簡単に SSR を実装する流れ

不要な既存機能

今回の内容を実装するに当たってgetServerSidePropsgetInitialPropsReact Server Componentsは必要ありません。特殊なものを仕込んだりはしないのでnext.config.jsの設定も不要です。_app.tsx に何か書く必要もありません。

非同期データがコンポーネント上で簡単に SSR で出力可能

非同期データを扱う最小限のサンプルコードです。コンポーネント上で非同期の'Hello world!'を返していますが、きちんと初期レンダリングで HTML 上に出力されています。もちろん fetch で取得したデータも利用出来ますが、そちらは後ほど紹介します。

src/pages/simple.tsx

非同期データが必要なコンポーネントを<SSRProvider>で囲み、データが必要なところでuseSSR経由で 非同期処理(fetch 等) を呼び出します。React Server Componentsのようなステートが持てないというような制約はありません。出力したコンポーネントはクライアントで自由に動かせます。

import { SSRProvider, useSSR } from "next-ssr";

const Test = () => {
  const { data } = useSSR(async () => "Hello world!");
  return <div>{data}</div>;
};

const Page = () => {
  return (
    <SSRProvider>
      <Test />
    </SSRProvider>
  );
};
export default Page;

以下が出力後の HTML データです。<div>Hello world!</div>が入っているのが確認出来ます。

<!DOCTYPE html>
<html>
  <head>
    <style data-next-hide-fouc="true">
      body {
        display: none;
      }
    </style>
    <noscript data-next-hide-fouc="true">
      <style>
        body {
          display: block;
        }
      </style>
    </noscript>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="next-head-count" content="2" />
    <noscript data-n-css=""></noscript>
    <script
      defer=""
      nomodule=""
      src="/_next/static/chunks/polyfills.js?ts=1677395377171"
    ></script>
    <script
      src="/_next/static/chunks/webpack.js?ts=1677395377171"
      defer=""
    ></script>
    <script
      src="/_next/static/chunks/main.js?ts=1677395377171"
      defer=""
    ></script>
    <script
      src="/_next/static/chunks/pages/_app.js?ts=1677395377171"
      defer=""
    ></script>
    <script
      src="/_next/static/chunks/pages/simple.js?ts=1677395377171"
      defer=""
    ></script>
    <script
      src="/_next/static/development/_buildManifest.js?ts=1677395377171"
      defer=""
    ></script>
    <script
      src="/_next/static/development/_ssgManifest.js?ts=1677395377171"
      defer=""
    ></script>
    <noscript id="__next_css__DO_NOT_USE__"></noscript>
  </head>
  <body>
    <div id="__next">
      <div>Hello world!</div>
      <script id="__NEXT_DATA_PROMISE__" type="application/json">
        { ":R2m:": { "data": "Hello world!", "isLoading": false } }
      </script>
    </div>
    <script src="/_next/static/chunks/react-refresh.js?ts=1677395377171"></script>
    <script id="__NEXT_DATA__" type="application/json">
      {
        "props": { "pageProps": {} },
        "page": "/simple",
        "query": {},
        "buildId": "development",
        "nextExport": true,
        "autoExport": true,
        "isFallback": false,
        "scriptLoader": []
      }
    </script>
  </body>
</html>

天気予報を fetch して SSR する例

次は少々コードが長くなりますが、天気予報を気象庁のサイトから持ってきて、SSR で出力する例です。Reload ボタンを用意してあるので再 fetch も可能です。

src/pages/index.tsx

import { SSRProvider, useSSR } from "next-ssr";

export interface WeatherType {
  publishingOffice: string;
  reportDatetime: string;
  targetArea: string;
  headlineText: string;
  text: string;
}

/**
 * Data obtained from the JMA website.
 */
const fetchWeather = (id: number): Promise<WeatherType> =>
  fetch(
    `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`
  )
    .then((r) => r.json())
    .then(
      // Additional weights (500 ms)
      (r) => new Promise((resolve) => setTimeout(() => resolve(r), 500))
    );

/**
 * Components for displaying weather information
 */
const Weather = ({ code }: { code: number }) => {
  const { data, reload, isLoading } = useSSR<WeatherType>(
    () => fetchWeather(code),
    { key: code }
  );
  if (!data) return <div>loading</div>;
  const { targetArea, reportDatetime, headlineText, text } = data;
  return (
    <div
      style={
        isLoading ? { background: "gray", position: "relative" } : undefined
      }
    >
      {isLoading && (
        <div
          style={{
            position: "absolute",
            color: "white",
            top: "50%",
            left: "50%",
          }}
        >
          loading
        </div>
      )}
      <h1>{targetArea}</h1>
      <button onClick={reload}>Reload</button>
      <div>
        {new Date(reportDatetime).toLocaleString("ja-JP", {
          timeZone: "JST",
        })}
      </div>
      <div>{headlineText}</div>
      <div style={{ whiteSpace: "pre-wrap" }}>{text}</div>
    </div>
  );
};

/**
 * Page display components
 */
const Page = () => {
  return (
    <SSRProvider>
      <a href="https://github.com/SoraKumo001/next-use-ssr">Source Code</a>
      <hr />
      {/* Chiba  */}
      <Weather code={120000} />
      {/* Tokyo */}
      <Weather code={130000} />
      {/* Kanagawa */}
      <Weather code={140000} />
    </SSRProvider>
  );
};
export default Page;

動作画面

コンソールに表示されている通り初期 HTML の時点でデータが揃っています。Reload ボタンを押した場合は、クライアントの処理として気象庁のサイトからデータを再 fetch します。

hacker-news を fetch して SSR する例

処理として news のリスト番号を取得し、その後に個別のニュースデータを取得しています。そのため fetch がネストして書いていますが、特に問題なく動作します。

src/pages/news.tsx

useSSRはネストさせる場合 key を設定する必要があります。key を省略した場合 useId を用いてキャッシュの名称を決めているのですが、ネストした場合は useId の戻り値がサーバとクライアントで不整合を起こすため、明確に指定しなければなりません。

import { Fragment, useState } from "react";
import { NextPage } from "next";
import { SSRProvider, useSSR } from "next-ssr";

const FETCH_WAIT = 50;
const PAGE_SIZE = 30;

type NewsType = {
  id: number;
  title: string;
  time: number;
  url: string;
  by: String;
  score: number;
  descendants: number;
  kids: number[];
  text: string;
};

const newsFetch = async (id: number): Promise<NewsType> => {
  return fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)
    .then(
      (v) =>
        new Promise<Response>((resolve) =>
          setTimeout(() => resolve(v), FETCH_WAIT)
        )
    )
    .then((v) => v.json());
};

const News = ({ id }: { id: number }) => {
  const { data, reload } = useSSR(
    () => newsFetch(id),
    // Name of the data to be passed to the client during SSR.
    { key: `news-${id}` }
  );
  if (!data) return null;
  const { title, time, url, by, score, descendants } = data;
  return (
    <div>
      <div>
        <button onClick={reload}>Reload</button> <a href={url}>{title}</a>
      </div>
      <div>
        {score} point:{score} by {by}
        {new Date(time * 1000).toLocaleString("en-us", {
          timeZone: "UTC",
        })} | comment:{descendants}
      </div>
    </div>
  );
};

const newsListFetch = (): Promise<number[]> => {
  return fetch(`https://hacker-news.firebaseio.com/v0/topstories.json`)
    .then(
      (v) =>
        new Promise<Response>((resolve) =>
          setTimeout(() => resolve(v), FETCH_WAIT)
        )
    )
    .then((v) => v.json());
};

const NewsList = () => {
  const { data, reload } = useSSR(() => newsListFetch());
  const [page, setPage] = useState(1);
  if (!data) return null;
  const maxPage = Math.floor(data?.length / PAGE_SIZE);
  return (
    <div>
      <div>
        <button onClick={reload}>Reload All</button>{" "}
        <button onClick={() => setPage(Math.max(page - 1, 1))}>Previous</button>{" "}
        {page}/{maxPage}{" "}
        <button onClick={() => setPage(Math.min(page + 1, maxPage))}>
          Next
        </button>
      </div>
      {data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).map((id) => (
        <Fragment key={id}>
          <hr />
          <News id={id} />
        </Fragment>
      ))}
    </div>
  );
};

const Page: NextPage = () => {
  return (
    <SSRProvider>
      <NewsList />
    </SSRProvider>
  );
};
export default Page;

色々解説

SSR の面倒くさいポイント

Next.js で非同期データを使った SSR を行う場合、通常は getServerSidePropsgetInitialProps を使用します。それらによって SSR 時にデータ取得処理を実行し、コンポーネントに渡す必要があります。また、クライアント側でデータの再取得が必要な場合、コンポーネント内にもデータ取得処理を書く必要があります。React Server Components を使用する場合は、コンポーネント内にデータ取得処理を記述できますが、ステートが持てないためクライアント側で再取得することはできず、コンポーネントごと再要求する必要があります。総じて、SSR の実装は面倒です。

サーバ側のコンポーネントレンダリング中に非同期を待つ方法

なぜこのような面倒な処理が必要かというと、Next.js というよりは React の制約のためです。コンポーネントのレンダリングが実行されると非同期を待つことが出来ず、サーバ上ですぐに終了してしまうためです。非同期処理を含めること自体は可能ですが、データ取得命令を発行した直後にレンダリングが完了し、データが到着するのは HTML をクライアントへ送った後になります。React Server Componentsを使った場合、コンポーネントそのものを非同期化出来るので待つことは可能ですが、ステートを持てないという巨大なデメリットを受け入れなければなりません。

しかし最初に提示したプログラムでは、普通のコンポーネントが非同期処理の待機をしています。React は実はコンポーネントのレンダリング中に非同期処理を待機することができるのです。この動作を行う唯一の方法は throw promise を使用することです。これは、Suspense と一緒に使用することが前提だと思われていますが、その必要はありません。サーバ側で非同期待機を行いたい場合に使用することができ、promise が解決されたときに再度レンダリングされます。

ちなみに今回は Next.js でサンプルを作っていますが、React の renderToPipeableStream などと組み合わせて、React だけでも同様のことは可能です。今回作ったライブラリで書いたテストは jest 上でそちらを使っています。

https://github.com/ReactLibraries/usessr/blob/master/test/server.test.tsx

サーバ側での非同期待機は特別な方法ではない

サーバ側で throw promise による非同期待機は、React Server Components では普通に利用されている方法です。しかしこれを使った場合、前述したとおり出力したコンポーネントに対してステートが持てないという制約があるので、使いどころが難しくなります。この制約を受けたくなければ、React Server Components を使用する代わりに、通常のコンポーネントで同様のことが行えるようにすれば良いのです。

通常コンポーネントでthrow promiseを使った場合の問題点

React Server Components では、非同期データを扱う場合でも出力される HTML に必要なデータが自動的に組み込まれるため、データを取り扱うために追加の考慮が必要ありません。一方、通常のコンポーネントでは、クライアント側で再マウントされた際にデータが消えてしまいます。HTML に表示用のデータが入っていても、これをコンポーネントがデータとして受け取れないからです。この場合、クライアント側で再フェッチが必要になり SSR の意味が失われることになります。

サーバで生成したthrow promiseのデータをクライアントで有効にする方法

コンポーネントをレンダリングして出力すると、HTML の形式でクライアントに渡すことができますが、これはあくまで見た目のデータを渡しているだけです。再レンダリングにコンポーネントが扱うデータとしては共有されません。そこで、非同期データを JSON 形式に変換して、クライアント側で理解できる形で渡す必要があります。getServerSideProps や getInitialProps を使った場合は、Next.js がこの動作を自動的に行ってくれます。それらを使わない場合は、この処理を手動で実装する必要があります。

<Suspense>は不要

throw promiseとセットで使われることが多い<Suspense>ですが、今回全く出てきていない通り特に使い道はありません。もし使うとするとクライアント側のローディング処理を簡略化する場合かStreaming SSRを行う場合です。前者は throw 対象のコンポーネントと入れ替えて Loading 表示という大味極まりない仕様なので、それなりに適当な UI を設計する時しか使えない機能です。後者はNext.js@13.2時点(13.1 系統までは使用可能)で pages 以下では使用不能になったので意味を成さなくなりました。Streaming SSRは appDir でやれという意図なのでしょう。

getInitialProps が必要になるケース

Next.js はデフォルトでページコンポーネントを静的最適化します。サーバ上で useRouter を使って fetch に渡すパラメータを扱うような場合、空処理の getInitialProps を加えて静的最適化を切らないと正常に動作しないので気をつけてください。

一連の処理をライブラリ化したもの

前述した処理を一通り行っているのが以下のソースです。非同期データ待ちと、揃ったデータをクライアントに渡す処理を行っています。

https://www.npmjs.com/package/next-ssr

import React, {
  ReactNode,
  useContext,
  useId,
  useRef,
  useCallback,
  useSyncExternalStore,
  createContext,
} from "react";

const DATA_NAME = "__NEXT_DATA_PROMISE__";

type StateType<T> = {
  data?: T;
  error?: unknown;
  isLoading: boolean;
  fetcher: () => Promise<T>;
};
type Render = () => void;
type ContextType = {
  values: { [key: string]: StateType<unknown> };
  promises: Promise<unknown>[];
  finished: boolean;
  renderMap: Map<string | number, Set<Render>>;
};

/**
 * Context for asynchronous data management
 */
const promiseContext = createContext<ContextType>(undefined as never);

/**
 * Rendering event propagation
 */
const render = (
  renderMap: Map<string | number, Set<Render>>,
  key: string | number
) => renderMap.get(key)?.forEach((render) => render());

/**
 * Asynchronous data loading
 */
const loader = <T,>(
  key: string | number,
  context: ContextType,
  fetcher?: () => Promise<T>
) => {
  const { promises, values, renderMap } = context;
  const _fetcher = fetcher ?? values[key]?.fetcher;
  if (!_fetcher) throw new Error("Empty by fetcher");
  const value = {
    data: values[key]?.data,
    error: undefined,
    isLoading: true,
    fetcher: _fetcher,
  };
  values[key] = value;
  render(renderMap, key);
  const promise = _fetcher();
  if (typeof window === "undefined") {
    promises.push(promise);
  }
  promise
    .then((v) => {
      values[key] = {
        data: v,
        error: undefined,
        isLoading: false,
        fetcher: _fetcher,
      };
      render(renderMap, key);
    })
    .catch((error) => {
      values[key] = {
        data: undefined,
        error,
        isLoading: false,
        fetcher: _fetcher,
      };
      render(renderMap, key);
    });
  return promise;
};

/**
 * hook for re-loading
 */
export const useReload = (key: string | number) => {
  const context = useContext(promiseContext);
  return useCallback(() => {
    loader(key, context);
  }, [context, key]);
};

/**
 * Asynchronous data acquisition hook for SSR
 */
export const useSSR = <T,>(
  fetcher: () => Promise<T>,
  { key }: { key?: string | number } = {}
): StateType<T> & { reload: () => void } => {
  const context = useContext(promiseContext);
  const { values, renderMap } = context;
  const id = useId();
  const cacheKey = key ?? id;

  const value = useSyncExternalStore(
    (callback) => {
      const renderSet = renderMap.get(cacheKey) ?? new Set<Render>();
      renderMap.set(cacheKey, renderSet);
      renderSet.add(callback);
      return () => renderSet.delete(callback);
    },
    () => values[cacheKey] as StateType<T>,
    () => values[cacheKey] as StateType<T>
  );

  const reload = useCallback(() => {
    return loader(cacheKey, context, fetcher);
  }, [cacheKey, context, fetcher]);
  if (!value) {
    const promise = reload();
    if (typeof window === "undefined") {
      throw promise;
    }
  } else if (!value.fetcher) {
    value.fetcher = fetcher;
  }

  return { ...value, reload };
};

/**
 * Transfer of SSR data to clients
 */
const DataRender = () => {
  const context = useContext(promiseContext);
  if (typeof window === "undefined" && !context.finished)
    throw Promise.allSettled(context.promises).then((v) => {
      context.finished = true;
      return v;
    });
  return (
    <script
      id={DATA_NAME}
      type="application/json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(context.values) }}
    />
  );
};

/**
 * Context data initialisation hook
 */
const useContextValue = () => {
  const refContext = useRef<ContextType>({
    values: {},
    promises: [],
    finished: false,
    renderMap: new Map<string, Set<Render>>(),
  });
  if (typeof window !== "undefined" && !refContext.current.finished) {
    const node = document.getElementById(DATA_NAME);
    if (node) refContext.current.values = JSON.parse(node.innerHTML);
    refContext.current.finished = true;
  }
  return refContext.current;
};

/**
 * Provider for asynchronous data management
 */
export const SSRProvider = ({ children }: { children: ReactNode }) => {
  const value = useContextValue();
  return (
    <promiseContext.Provider value={value}>
      {children}
      <DataRender />
    </promiseContext.Provider>
  );
};

まとめ

Next.js の SSR では、非同期データ待ちが throw promise によって可能になり、実装が簡単になりました。この記事では、fetch を使って非同期データを取得する方法を紹介していますが、urql など、サーバ側で throw promise が可能なライブラリを使用する場合も同じように実装ができます。

https://zenn.dev/sora_kumo/articles/661e1abc1cda67

一方で、Recoil や SWR などは対応を試みましたが、現時点ではthrow promiseによる SSR の実装は構造的に不可能でした。@apollo/client は 3.8 系から対応可能となり、試しに作った検証コードでは成功しているものの、まだ 3.8 がアルファ段階です。

今後、throw promiseを内部で呼び出してくれるライブラリが増えることで、SSR の実装がより簡単になることが期待できます。

ちなみに Vercel は SSR 関連の機能として appDir の方を推し進めたいようですが、初っぱなからReact Server Componentsでぶちかますあの仕様は、かなり用途が限定されそうです。

GitHubで編集を提案

Discussion