最近、個人サイトを再構築しているため、NextJS の新しい RSC アーキテクチャを試した結果、多くの落とし穴にハマりました。この文書では、いくつかの実践を記録するつもりです。
SSR アーキテクチャでは、リクエストデータがサーバー側にある場合、CSR レンダリングに移行する際に SSR のデータに依存する必要があります。そのため、CSR 時に取得するデータがサーバー側と一致していることを保証する必要があります。そうしないと、両方のレンダリングが一致せず、ハイドレーションが成功しません。そうすると、Hydration failed because the initial UI does not match what was rendered on the server.というエラーが発生します。このエラーはページのクラッシュを引き起こすことはありませんが、使用中に明らかな LCP の低下はありません。しかし、開発中は非常にストレスが溜まります。大量の NextJS の赤いポップアップや、プロダクション環境での Sentry の爆撃(Sentry を接続している場合)が発生します。以下の画像は現在のkamiの厄介な体験です。どうしても変更できなかったため、再構築のアイデアが生まれました。
RSC アーキテクチャでも SSR を基盤としており、現在はルーティングが完全にサーバーによって管理されています。そのため、元々の NextJS のルーターは完全に置き換えられました。ルーティングレンダリングは、最上位コンポーネントから下に向かってサーバーによってレンダリングされ、クライアントに返されます。理論的には、use client
のコンポーネントに遭遇しない限り、ブラウザ側ではレンダリングを行う必要はありません。ほとんどのプロジェクトでは、ビジネスがそんなに簡単ではありません。例えば、私のデータはサーバー側のイベントのプッシュに応じて変化します。
注意が必要なのは、ブラウザのハイドレーション時にデータが一致していることを保証する必要があるということです。これができない場合、そのコンポーネントの SSR レンダリングを放棄するしかありません。最も一般的な方法ですが、これ以上のことはできません。
// app/pageImpl.tsx
'use client'
export default (props: { data: PageData }) => {
// ...
}
// app/page.tsx
import PageImpl from './pageImpl'
export default async () => {
const data = await fetch('...')
return <PageImpl data={data} />
}
上記は私が最初に試みたデータ伝達方法です。この方法を使用すれば、渡されるdata
がすべて JSON シリアライズ可能である限り、全く問題ありません。
しかし、上記の方法を使用すると、props
を通じて渡されるデータは不変であり、ページのコンポーネントはこのデータによって駆動され、様々なイベントに応じてこのデータを変更する必要があるため、状態管理を導入する必要があります。
すでにひどい状態の kami は、どのように実装されているのでしょうか。ページに必要なデータがリクエストされた後、サーバー側で取得したデータに基づいてページをレンダリングし、HTML をブラウザに返します。ブラウザがレンダリングを開始する最初のフレームはページの完全な状態ですが、この時点ではページはインタラクティブな状態ではありません。JS が読み込まれた後、React が介入してハイドレートを開始しますが、ページのデータは props を通じて渡されるのではなく、すべてストアから抽出されます。この時点でストアがハイドレーションを完了していないため、ハイドレート後の最初のフレームはデータのないページの読み込み状態に入り、React がハイドレーションエラーを報告し、クライアントレンダリングに切り替わります。
以前使用していた Zustand は、良い解決策を提供していないようです。今回は Jotai を使用してこの部分の移行を完了するつもりです。私たちのページデータは依然としてストアによって駆動され、props を透過的に使用することはありません。
React Query 方案#
私は React Query を媒介として試しました。React Query は自然にハイドレートコンポーネントを提供し、ある程度この問題を解決できますが、データ管理として React Query を使用すると、各コンポーネントの粒度を制御できなくなります。React Query の select 機能はあまり柔軟ではなく、いくつかの試みの中で、select を使用しても各コンポーネントの更新に正確に粒度を合わせることができないことがわかりました。
本当に簡単ですか?#
React Query のソリューションを使用する場合、簡単なシナリオでは以下の操作だけで済みます。
ReactQueryProvider と Hydrate コンポーネントを作成します。これは 2 つのクライアントコンポーネントです。
// app/provider.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PropsWithChildren } from 'react'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5分
refetchInterval: 1000 * 60 * 5, // 5分
refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
},
},
})
export const ReactQueryProvider = ({ children }: PropsWithChildren) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
// app/hydrate.tsx
'use client'
import { Hydrate as RQHydrate } from '@tanstack/react-query'
import type { HydrateProps } from '@tanstack/react-query'
export function Hydrate(props: HydrateProps) {
return <RQHydrate {...props} />
}
次に、layout.tsx
にインポートします。
import { QueryClient, dehydrate } from '@tanstack/react-query'
import { Hydrate } from './hydrate'
import { ReactQueryProvider } from './provider'
import { QueryKeys } from './query-key'
import { sleep } from '@/utiils'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 1000,
staleTime: 1000,
},
},
})
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
await queryClient.fetchQuery({
queryKey: [QueryKeys.ROOT],
queryFn: async () => {
await sleep(1000)
const random = Math.random()
console.log(random)
return random
},
})
const state = dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
if (query.state.error) return false
if (!query.meta) return true
const {
shouldHydration,
skipHydration,
} = query.meta
if (skipHydration) return false
return (shouldHydration as Function)?.(query.state.data as any) ?? false
},
})
return (
<ReactQueryProvider>
<Hydrate state={state}>
<html lang="en">
<body>{children}</body>
</html>
</Hydrate>
</ReactQueryProvider>
)
}
ここで注意が必要なのは、サーバー側でも QueryClient を作成する必要があることです。サーバーコンポーネント専用のこの QueryClient はクライアントコンポーネントとは異なり、ハイドレート時にはサーバーサイドのものを使用します。したがって、layout.tsx
では、サーバーサイド専用の QueryClient を再度作成しています。RootLayout では、クエリのフェッチを定義し、ランダムデータの取得をシミュレートし、この非同期リクエストが完了するのを待ってから Dehydrate 段階に入ります。上記で設定したcacheTime
に注意してください。次に、ハイドレートが有効かどうかを検証します。ハイドレーションエラーが発生しなければ、問題はありません。
page.tsx
を作成し、クライアントコンポーネントに変換します。
'use client'
import { useQuery } from '@tanstack/react-query'
import { QueryKeys } from './query-key'
export default function Home() {
const { data } = useQuery({
queryKey: [QueryKeys.ROOT],
queryFn: async () => {
return 0
},
enabled: false,
})
return <p>Hydrate Number: {data}</p>
}
ここでは、クエリの自動 refetch 機能を無効にして、データが更新されないようにしています。この例では、ページが 0 を表示しない限り、問題ありません。
ランダム数がサーバーの出力と一致しており、ブラウザにハイドレーションエラーが表示されていないことがわかります。
データキャッシュ#
前述のように、サーバーサイド専用の QueryClient でcacheTime
を設定しました。このパラメータは、あなたが考えるデータキャッシュ時間ではなく、Query インスタンスの存在時間です。React Query では、すべてのクエリが QueryCache にホストされており、この時間を過ぎるとクエリは破棄されます。React Hook の useQuery では、クエリがコンポーネントに長期間保持されているため、この数値を意識する必要はありませんが、QueryClient で手動でフェッチしたデータも Query インスタンスを生成します。したがって、サーバーサイドでは、同じクエリに対してデータが何度もヒットするようにし、短すぎる時間を設定しないようにしてください。デフォルト値は 5 分です。
例として、サーバーサイドの QueryClient のcacheTime
を 10 ミリ秒に設定した場合、queryClient がデータをフェッチする際に非同期タスクが挿入され、dehydrate に進む前に Query インスタンスが破棄される可能性があります。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
cacheTime: 10, // 10msに設定、サーバーがAPIキャッシュを長期間ヒットしないようにするためかもしれません。
},
},
})
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
await queryClient.fetchQuery({
queryKey: [QueryKeys.ROOT],
queryFn: async () => {
await sleep(1000)
const random = Math.random()
console.log(random)
return random
},
})
await sleep(10) // 非同期タスクのシミュレーション、10msを超えます
const state = dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
if (query.state.error) return false
if (!query.meta) return true
const {
shouldHydration,
skipHydration,
} = query.meta
if (skipHydration) return false
return (shouldHydration as Function)?.(query.state.data as any) ?? false
},
})
return (
<ReactQueryProvider>
<Hydrate state={state}>
<html lang="en">
<body>{children}</body>
</html>
</Hydrate>
</ReactQueryProvider>
)
}
この時点でブラウザのページを再確認すると、データがなくなっています。
考えてみると、React Query を使用し、サーバーが API のキャッシュを自身に保持したくない場合は、少し難しいです。
潜在的なデータ漏洩#
もしあなたが Serverless Mode でこの Next.js を実行していない場合、サーバー側の QueryClient は 1 つしか存在しませんが、あなたのサイトに多くのユーザーがアクセスしていると、QueryClient は異なるリクエストデータをキャッシュします。
A ユーザーがサイトにアクセスしているとき、B ユーザーのアクセス内容のハイドレーションデータが含まれている可能性があります。
例として、デモを作成します。サーバーサイドのcacheTime
をコメントアウトし、デフォルトの 5 分に戻します。
A と B のページを作成します。
// app/A/layout.tsx
import { queryClient } from '../queryClient.server'
export default async () => {
await queryClient.fetchQuery({
queryKey: ['A'],
queryFn: async () => {
return 'This is A'
},
})
return null
}
// app/A/page.tsx
export default () => {
return null
}
B も同様に、上記の A をすべて B に変更します。
/A
と/B
にアクセスします。ページをリフレッシュし、/A
の HTML ソースを確認します。
/A
にアクセスすると、/B
のデータが含まれていることがわかります。
アクセス量が増えると、このハイドレーションデータは非常に大きくなり、私たちが望まないものになります。また、Cookie をサーバーに転送すると、訪問者が見てはいけないものを見る可能性があります。
回避策として、私の提案は meta を基に判断することです。クエリの定義に、ハイドレーションが必要かどうかを示すカスタム meta キーを追加できます。そして、現在のルートに従って、現在のルートのデータのみをハイドレートします。敏感な内容(認証が必要なものや一部の閲覧が可能なもの)については、ハイドレーションを強制的にスキップします。
const dehydratedState = dehydrate(queryClient, {
shouldDehydrateQuery: (query) => {
if (query.state.error) return false
if (!query.meta) return true
const {
shouldHydration,
hydrationRoutePath,
skipHydration,
forceHydration,
} = query.meta
if (forceHydration) return true
if (hydrationRoutePath) {
const pathname = headers().get(REQUEST_PATHNAME)
if (pathname === query.meta?.hydrationRoutePath) {
if (!shouldHydration) return true
return (shouldHydration as Function)(query.state.data as any)
}
}
if (skipHydration) return false
return (shouldHydration as Function)?.(query.state.data as any) ?? false
},
})
dehydrateState を変更するだけで済みます。ここでは、shouldHydration
、hydrationRoutePath
、skipHydration
、forceHydration
を使用してハイドレーション状態を制御しています。
参考使用方法:
defineQuery({
queryKey: ['note', nid],
meta: {
hydrationRoutePath: routeBuilder(Routes.Note, { id: nid }),
shouldHydration: (data: NoteWrappedPayload) => {
const note = data?.data
const isSecret = note?.secret
? dayjs(note?.secret).isAfter(new Date())
: false
return !isSecret
},
},
queryFn: async ({ queryKey }) => {
const [, id] = queryKey
if (id === LATEST_KEY) {
return (await apiClient.note.getLatest()).$serialized
}
const data = await apiClient.note.getNoteById(+queryKey[1], password!)
return { ...data }
},
})
ここまで来ると、RootLayout コンポーネント内で新しい QueryClient インスタンスを再構築する必要があるのではないかと言うかもしれません。そうすれば、各リクエストのデータが汚染されないことが保証されます。確かに、React Query のドキュメントで提案されているソリューションもそうですが、これは従来の SSR アーキテクチャにのみ適用され、多くの制限があります。この方法を使用しない場合、QueryClient は他の Layout から呼び出すことができず、子 Layout 内のデータフェッチングには新しい QueryClient を作成する必要があり、再度 Hydrate コンポーネントでラップすることは多くの追加コストを伴います。
React 18.3で提供されているcache
(Next.js はこの方法を実装しています)メソッドは、この問題を解決するかもしれません。cache(() => new QueryClient())
でラップすることで、この React DOM レンダリング中に常に同じ QueryClient にヒットします。この方法は、リクエストの状態汚染を解決しますが、高い同時実行性の中で単一インスタンスのリクエストデデュープの利点を享受できず、瞬時に多くのリクエストを発信することによる過負荷も考慮する必要があります。
ここではこれ以上詳しく述べることはありません。
要するに、React Query では多くの問題を考慮する必要があり、複雑さが増し、他のソリューションに移行することを促しています。
Jotai#
書き疲れました。次回に続きます。
この記事は Mix Space によって xLog に同期更新されました。原始リンクは https://innei.ren/posts/programming/nextjs-rsc-ssr-data-hydration-persistence