サーバーとブラウザの境界
Reactのディレクティブで、サーバーで動くコードとブラウザで動くコードを書き分けます。Client Componentに渡せる値と、サーバー専用のモジュールをクライアントに含めない方法も分かります。
このページの内容
Server Component
src/lib/catalog.server.tsimport 'server-only';
export type Product = { id: number; name: string };
const products: readonly Product[] = [
{ id: 1, name: 'Lamp' },
{ id: 2, name: 'Desk' },
];
export const listProducts = async (): Promise<readonly Product[]> =>
products;src/routes/products/page.tsximport { href } from '@k8ordo/framework';
import { listProducts } from '../../lib/catalog.server';
export default async function ProductsPage() {
Server Componentは`async`にできる
const products = await listProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<a href={href('/products/:id', { id: product.id })}>
{product.name}
</a>
</li>
))}
</ul>
);
}ディレクティブの無いファイルは、Server Componentです。データを直接読めます。ブラウザに送られるのは描画した結果だけで、コードは送られません。
Server Componentが動く時点は、モードで違います。staticモードではビルドのときに動きます。ページごとにHTML用とindex.rsc用の2回描画し、その結果がファイルになります。serverモードではリクエストのたびに動き、その時点のデータを読みます。vite devでは、どちらのモードもリクエストごとに動きます。
'use client'
src/components/counter.tsx'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button
onClick={() => {
setCount(count + 1);
}}
type="button"
>
{count}
</button>
);
}src/routes/page.tsximport { Counter } from '../components/counter';
export default function HomePage() {
return (
<>
<h1>Home</h1>
<Counter />
</>
);
}ブラウザで動かすコンポーネントは、ファイルの先頭に'use client'を書きます。Server Componentからは、ほかのモジュールと同じようにimportします。ブラウザに送られるのは、そのコンポーネントとそれがimportするものだけです。
Client Componentも、サーバー側で一度HTMLに描画されてから、ブラウザでハイドレーションされます。localStorageのようにサーバーで読めない値の読み方は、下の「ブラウザだけの値」を見てください。
'use server'は、ブラウザから呼べるサーバーの関数を宣言します。ブラウザに送ってはいけないモジュールに付けるserver-onlyとは役割が違います。serverモードだけで使え、staticモードではビルドとvite devが拒みます。書き方はServer Actionを見てください。
渡せるprops
Server ComponentからClient Componentへ渡すpropsはシリアライズされるので、渡せる値は次のものに限られます。
- プリミティブ:文字列と数値、真偽値。
nullとDateも渡せます - コレクション:プレーンなオブジェクトと配列、
Map、Set Promise:Client Componentがuse()で待てます- JSX:Server Componentが描画した
childrenも渡せます
関数とクラスのインスタンスは渡せません。関数を渡すと、そのページの描画がReactのエラー(Functions cannot be passed directly to Client Components)で失敗します。staticモードではビルドできません。例外はServer Actionで、serverモードでは'use server'の関数を参照として渡せます。
レイアウトの分割
src/routes/[locale]/layout.tsximport type { ReactNode } from 'react';
import { LocaleShell } from '../../components/locale-shell';
import { locales } from '../../i18n';
export const { paramsSchema } = locales;
スキーマはここに置く
export default function LocaleLayout({
children,
}: {
children: ReactNode;
}) {
return <LocaleShell>{children}</LocaleShell>;
}src/components/locale-shell.tsx'use client';
import { UIProvider } from '@k8ordo/ui';
import type { ReactNode } from 'react';
export function LocaleShell({ children }: { children: ReactNode }) {
return <UIProvider>{children}</UIProvider>;
}paramsSchemaは、'use client'の無いファイルからしかexportできません。一方、フックやプロバイダを使うレイアウトはClient Componentです。両方が要るときは、スキーマを持つlayout.tsxと、それが描画するClient Componentに分けます。
サーバーで描画したchildrenは、JSXとしてClient Componentに渡せます。このサイトの[locale]のレイアウトも、この形です。
ブラウザだけの値
src/components/editor.tsx'use client';
import { Suspense, use } from 'react';
import { browser } from 'react-dom';
function SavedDraft() {
use(browser('the draft is in localStorage'));
`SavedDraft`はブラウザでだけ描画される
const draft = localStorage.getItem('draft') ?? '';
return <textarea defaultValue={draft} />;
}
export function Editor() {
return (
<Suspense fallback={<p>Loading the draft…</p>}>
サーバー側の描画ではここが表示される
<SavedDraft />
</Suspense>
);
}localStorageや訪問者のタイムゾーンのように、ブラウザにしか無い値を読むClient Componentは、react-domのbrowser()をuse()に渡します。typeof windowの分岐や、マウント済みかどうかのフラグは要りません。
サーバー側の描画は、HTMLにfallbackを残します。ブラウザはハイドレーションの後に、そのコンポーネントを描画します。これは失敗ではないので、ビルドのエラーにはならず、ログにも何も出ません。
落とし穴
上に<Suspense>が無いと、サーバー側の描画がThe server render could not complete because client rendering was requested outside a Suspense boundaryで失敗します。
server-only
npm install server-onlyvite build'server-only' cannot be imported in client build ('ssr' environment):
imported by src/lib/catalog.server.ts
imported by src/components/counter.tsx
imported by virtual:vite-rsc/client-referencesserver-onlyはアプリの依存に入れます。ビルドはインストールしなくても通りますが、TypeScriptの型検査には必要です。秘密の値やデータベースのクライアントを持つモジュールは、先頭でserver-onlyをimportします。このモジュールがクライアントのバンドルに入ると、上のエラーになり、ビルドできません。エラーには、そのモジュールに至ったimportの連鎖がすべて並びます。
間に何段のモジュールを挟んでも、同じエラーになります。こうしたファイルは*.server.tsと名付けると、開かなくてもサーバー専用だと分かります。
ブラウザでのURL
src/components/where.tsx'use client';
import { useMatch, usePathname } from '@k8ordo/framework';
export function Where() {
const pathname = usePathname();
const inProducts = useMatch('/products/*') !== null;
return <p data-products={inProducts}>{pathname}</p>;
}このフレームワークでは、ブラウザはルート表を持たず、ページのコンポーネントツリーをサーバーから受け取ります。そのため、useRoute()とuseParams()は@k8ordo/frameworkからexportしていません。
ページはparamsをpropsで受け取り、Client Componentには必要な値をpropsで渡します。今のURLはusePathname()で読みます。今のURLが/products/*のようなパターンに合うかは、useMatch()で調べます。
クエリ
フレームワークが扱うのは、URLのpathnameまでです。?から後ろのクエリは@k8ordo/stateが読みます。ページはクエリを受け取らず、Client ComponentのuseAppStateがブラウザで読みます。
サーバー側の描画にはurlの置き場所の既定値が使われ、ハイドレーションで実際のURLの値に変わります。クエリだけが変わってもページは変わらないので、再マウントもスクロールの移動も起きません。
serverモードには例外があり、searchをexportしたページは検証済みのクエリをsearchとして受け取ります。書き方はパラメータを見てください。staticモードでsearchをexportすると、static build cannot hand a page the searchで始まるエラーになり、ビルドできません。vite devも同じです。検索や絞り込みを作るなら、JavaScriptが読み込まれる前から動くGETのフォームが向いています。書き方は検索フォームを見てください。