空雲 Blog

Eye catchNext.jsのAppRouterを使い、ClientComponentsのみで非同期データのSSRを行う

publication: 2023/11/07
update:2024/02/20

最初に

今回のサンプルコードのリンク

AppRouter使用時の一般的な非同期データのSSR

AppRouterで非同期データを含んだSSRを行う場合、ServerComponentsでデータを取得して自身でレンダリングするか、それをClientComponentsに渡すかする必要があります。ブラウザ側でユーザの入力に合わせて柔軟に表示内容を変えようとするとServerComponentsとClientComponentsのやり取りをそれぞれ記述しなければならず、PagesRouter上でgetServerSidePropsからデータを渡していたときと労力に差がありません。

ClientComponentsで非同期データを扱えば、コードの記述が楽になる

ServerComponentsを使えば、その部分のコンポーネントのjsコードをクライアントに渡す必要がなくなり、少しだけ通信量が節約できます。ただ、微々たるものです。そのためにデータ取得用コンポーネントとUI用コンポーネントを分離するのは手間になります。だったらClientComponentsで非同期データを扱えるようにすれば良いのです。

必要なもの

  • Next.jsでSSRを簡単に行うためのパッケージ

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

以下のような形でNext.js環境下に追加でパッケージを入れます。

1yarn add next-ssr

単純な非同期SSRのサンプル

src/app/layout.tsx

SSR用のデータを取り扱うProviderをlayoutに設置します。これで配下のコンポーネントから、簡単SSR機能が使えるようになります。

1import { SSRProvider } from 'next-ssr';
2
3export const metadata = {
4 title: 'samples of next-ssr',
5 description: 'SSR with AppRouter.',
6};
7
8export default function RootLayout({ children }: { children: React.ReactNode }) {
9 return (
10 <html lang="en">
11 <body>
12 <SSRProvider>{children}</SSRProvider>
13 </body>
14 </html>
15 );
16}

src/app/simple/page.tsx

非同期データを扱うための最小限のコードです。非同期関数から'Hello world!'を渡しています。もちろんfetchなどで取得したデータも使えます。

1'use client';
2import { useSSR } from 'next-ssr';
3
4/**
5 * Page display components
6 */
7const Page = () => {
8 const { data } = useSSR(async () => 'Hello world!');
9 return <div>{data}</div>;
10};
11export default Page;

出力されたHTML

bodyタグの直後に、<div>Hello world!</div>が入っています。非同期データのSSRに成功しています。

{"width":"1062px","height":"323px"}

天気予報のサンプル

気象庁のサイトからデータをfetchして内容を表示しています。SSR後はClientComponentsなので、UIイベントで再ロードも行えます。その際はクライアント側でfetchが行われます。

src/app/weather/page.tsx

1'use client';
2import { useSSR } from 'next-ssr';
3
4export interface WeatherType {
5 publishingOffice: string;
6 reportDatetime: string;
7 targetArea: string;
8 headlineText: string;
9 text: string;
10}
11
12/**
13 * Data obtained from the JMA website.
14 */
15const fetchWeather = (id: number): Promise<WeatherType> =>
16 fetch(`https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`)
17 .then((r) => r.json())
18 .then(
19 // Additional weights (500 ms)
20 (r) => new Promise((resolve) => setTimeout(() => resolve(r), 500))
21 );
22
23/**
24 * Components for displaying weather information
25 */
26const Weather = ({ code }: { code: number }) => {
27 const { data, reload, isLoading } = useSSR<WeatherType>(() => fetchWeather(code), { key: code });
28 if (!data) return <div>loading</div>;
29 const { targetArea, reportDatetime, headlineText, text } = data;
30 return (
31 <div style={isLoading ? { background: 'gray', position: 'relative' } : undefined}>
32 {isLoading && (
33 <div style={{ position: 'absolute', color: 'white', top: '50%', left: '50%' }}>loading</div>
34 )}
35 <h1>{targetArea}</h1>
36 <button onClick={reload}>Reload</button>
37 <div>
38 {new Date(reportDatetime).toLocaleString('ja-JP', {
39 timeZone: 'JST',
40 })}
41 </div>
42 <div>{headlineText}</div>
43 <div style={{ whiteSpace: 'pre-wrap' }}>{text}</div>
44 </div>
45 );
46};
47
48/**
49 * Page display components
50 */
51
52const Page = () => {
53 return (
54 <>
55 {/* Chiba */}
56 <Weather code={120000} />
57 {/* Tokyo */}
58 <Weather code={130000} />
59 {/* Kanagawa */}
60 <Weather code={140000} />
61 </>
62 );
63};
64export default Page;
65

出力されたHTML

天気予報の内容がSSRされています。

{"width":"996px","height":"1161px"}

{"width":"1323px","height":"1559px"}

まとめ

AppRouterの本来の機能をガン無視しています。そのためpage.tsxに記述している内容は、PagesRouter上にそのまま持って行ってもファイル名だけ直せば同じ動きをします。

そもそのもところで、Next.jsがReactのレンダリングに使っているreact-dom/serverのrenderToPipeableStreamは、コンポーネントのthrow promiseで非同期待ちしてくれるので、ServerComponentsとClientComponentsの種別に関わりなく非同期待ちが可能です。あとは必要に応じて、データを配るコードを足してやれば便利に動くようになります。

ちなみにここのBlogシステムは今回紹介した動作原理をUrqlのプラグインとして実装して運用していますが、コードを書くのも楽だし、特になんの問題もなくキビキビ動いています。

ほとんどコードに違いがないので、PagesからAppの移植は簡単です。ただ、ページ遷移時にPagesの方がファイルの読み取り頻度が低いので、最終的にPagesのままにするという結論に至りました。ちなみにPagesRouter版でもAppRouterをAPIRouteの代替としては使ってます。

今のところAppRouterにメリットは感じないので趣味でいじりつつ、しばらくは様子見したいと思っています。