実行境界
何も書かなければサーバー、ブラウザ側は 'use client' で入る。境界は React 自身の語で宣言し、ビルドが検査します。このページは、このモードで Server Component がいつ動くか、境界を越えられるもの、ブラウザにしか無いものの読み方、クライアントに届いてはいけないモジュールを説明します。
既定は Server Component
ディレクティブの無いファイルは Server Component です。サーバー側でだけ動き、async にでき、データを直接読めます。コードはブラウザに送られず、送られるのは描いた結果だけです。
このモードでは、Server Component はリクエストのたびに動き、その時点のデータを読みます。
// src/routes/_data/catalog.server.ts
import 'server-only';
export type Product = { id: number; name: string };
const CATALOG: readonly Product[] = [
{ id: 1, name: 'first product' },
{ id: 2, name: 'second product' },
];
export const listProducts = (): readonly Product[] => CATALOG;// src/routes/products/page.tsx
import { href } from '@k8ordo/router';
import { listProducts } from '../_data/catalog.server';
export default function ProductsPage() {
const products = listProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<a href={href('/products/:id', { id: product.id })}>
{product.name}
</a>
</li>
))}
</ul>
);
}'use client' でブラウザ側に入る
ブラウザ側は React 自身の語 'use client' で宣言します。Server Component はそれを普通に import でき、境界を越えるのはそのコンポーネントだけで、ページはサーバーに残ります。'use client' のファイルが import するものは、すべてクライアントのバンドルに一緒に入ります。
// src/routes/_parts/counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [n, setN] = useState(0);
return (
<button
onClick={() => {
setN(n + 1);
}}
type="button"
>
{n}
</button>
);
}// src/routes/page.tsx
import { Counter } from './_parts/counter';
export default function HomePage() {
return (
<>
<h1>home</h1>
<Counter />
</>
);
}クライアントコンポーネントも、サーバーで一度 HTML に描かれてから、ブラウザで hydrate されます。サーバーでの描画では読めないもの(localStorage など)を読むときは、下の「ブラウザが必要なコンポーネント」の形にします。
'use server' はクライアントが呼べるサーバーの関数を宣言するもので、server-only とは別のことを言っています。 アクションとリクエスト
境界を越える props
Server Component からクライアントコンポーネントへ渡す props は、シリアライズされて運ばれます。渡せるのは文字列・数値・真偽値・null・プレーンなオブジェクトと配列・Date・Map・Set・Promise・JSX(Server Component が描いた children を含む)です。関数とクラスのインスタンスは渡せません。
// src/routes/page.tsx
import { Greeting } from './_parts/greeting';
export default function HomePage() {
return (
<Greeting renderedAt={new Date()} tags={['rsc', 'boundaries']}>
<p>rendered on the server</p>
</Greeting>
);
}// src/routes/_parts/greeting.tsx
'use client';
import type { ReactNode } from 'react';
export function Greeting({
renderedAt,
tags,
children,
}: {
renderedAt: Date;
tags: string[];
children: ReactNode;
}) {
return (
<section>
<time dateTime={renderedAt.toISOString()}>
{renderedAt.toISOString()}
</time>
<ul>
{tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
{children}
</section>
);
}関数を渡すと、そのページの描画が React のエラー(Functions cannot be passed directly to Client Components)で失敗します。例外は Server Action で、'use server' の関数は参照として境界を越えます。
このサイトの文言は message() で、呼ぶと文字列を返す関数です。Server Component からクライアントコンポーネントへ文言を渡すときは、関数ではなく、呼んだ結果の文字列を渡しています(label={m.x.y()})。
レイアウトを 2 つのファイルに割る
paramsSchema は Server Component のファイルからしか export できません。一方で、フックやプロバイダを使うレイアウトはクライアントコンポーネントです。このサイトの [locale] レイアウトは、これを 2 つのファイルに割っています。layout.tsx はスキーマを持つ Server Component で、_parts/locale-shell.tsx がプロバイダ・ヘッダー・フックを持つクライアント側の殻です。
// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';
import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';
export const { paramsSchema } = locales;
export default function LocaleLayout({
params,
children,
}: {
params: { locale: string };
children: ReactNode;
}) {
return <LocaleShell locale={params.locale}>{children}</LocaleShell>;
}// src/routes/[locale]/_parts/locale-shell.tsx
'use client';
import { usePathname } from '@k8ordo/router';
import { UIProvider } from '@k8ordo/ui';
import { dictionaries } from '@k8ordo/ui/i18n';
import type { ReactNode } from 'react';
import { locales } from '../../../i18n';
export function LocaleShell({
locale: param,
children,
}: {
locale: string;
children: ReactNode;
}) {
const pathname = usePathname();
const locale = locales.is(param)
? param
: (locales.delocalize(pathname).locale ?? locales.default);
return <UIProvider messages={dictionaries[locale]}>{children}</UIProvider>;
}どちらのファイルも、この節に関わる部分だけを抜き出しています。実際の殻は、ヘッダー・サイドバー・フッターも描きます。
レイアウトの params.locale は文字列として型が付きます。ページのまわりではスキーマがすでに受け付けた値ですが、not-found.tsx のまわりでは何も検証されず、どんな値でもありうるからです。そこで殻は locales.is() で確かめ、ロケールでなければ URL から読み直します。サーバーが描いた children は、JSX として境界を越えます。
ブラウザが必要なコンポーネント
ブラウザにしか無いもの(localStorage、訪問者のタイムゾーン、navigator)を読むクライアントコンポーネントは、react-dom の browser を使って use(browser()) でそう言い、<Suspense> の中に置きます。browser は React 19.3 で入った API です。
// src/routes/_parts/editor.tsx
'use client';
import { Suspense, use } from 'react';
import { browser } from 'react-dom';
function SavedDraft() {
use(browser('the draft is stored in localStorage'));
return <textarea defaultValue={localStorage.getItem('draft') ?? ''} />;
}
export function Editor() {
return (
<Suspense fallback={<p>loading the draft…</p>}>
<SavedDraft />
</Suspense>
);
}サーバーでの描画は、ファイルへのビルドでもリクエストへの応答でも、fallback を HTML に残し、ブラウザが hydration の後にコンポーネントを描きます。これは失敗ではありません。ビルドは止まらず、ハンドラも何もログに出しません。typeof window の検査や「マウント済み」のフラグが担っていた役目で、どちらも要りません。
<Suspense> は fallback を置く場所を決めるもので、省けません。上に Suspense の境界が 1 つも無ければ、サーバーでの描画は fallback を残す場所が無く、失敗します。
サーバー専用のモジュール
server-only を import したモジュールは、クライアントに届いてはいけません。
届いてしまうとビルドが失敗し、そこへ至った import の連鎖を名指します。クライアントコンポーネントのグラフは入口からたどるのではなく描画中に組み立てられますが、その経路も含みます。
'server-only' cannot be imported in client build ('ssr' environment):
imported by src/routes/_data/catalog.server.ts
imported by src/routes/_parts/counter.tsx
imported by virtual:vite-rsc/client-references秘密やデータベースのクライアントをこの import の後ろに置けば、間に何段のモジュールを挟んでも渡りません。server-only は React のエコシステムがこのために使うパッケージで、指定子はビルドが自分で解決します。インストールするのは、TypeScript に解決させるためです。
そうしたファイルは *.server.ts と名付けます。保証は import から来るもので、名前はファイルを開かなくても、ディレクトリ木と import 文の上で読み手に見えるようにするためのものです。自分では印を付けないサードパーティのモジュールも、こうしたファイルで包めば同じ検査の下に入ります。
ブラウザで「今どこか」を知る
フレームワークの下では、ブラウザはルート表を持ちません。ツリーはサーバーから届き、ブラウザにあるのはナビゲーションだけです。そのため useRoute() と useParams() は読むべき一致が無く、throw します。ページは params を prop として受け取り、クライアントコンポーネントには要るものを props で渡します。現在地は usePathname() で、ある区画が開いているかは useMatch() で尋ねます。
// src/routes/_parts/where.tsx
'use client';
import { useMatch, usePathname } from '@k8ordo/router';
export function Where() {
const pathname = usePathname();
const inProducts = useMatch('/products/*') !== null;
return <p data-section={inProducts ? 'products' : 'other'}>{pathname}</p>;
}フレームワークの下でのルーターの振る舞いは、リンク先にあります。 フレームワーク配下
search は @k8ordo/state のもの
ページは search を見ません。フレームワークが持つのは pathname で、? から後ろは @k8ordo/state のものです。useAppState はブラウザで search を読むので、サーバーでの描画は url スロットの既定値を描き、hydration で実際の URL に切り替わります。search だけが変わってもページは変わらず、何も再マウントされず、スクロール位置もそのままです。
アプリが @k8ordo/state に依存していれば、生成される register.gen.ts がその Register も書くので、状態の定義も同じルート表に対して型が付きます。 @k8ordo/state
このモードでページが受け取る request にも、search は含まれません。