@k8ordo/framework

サーバーとブラウザの境界

Reactのディレクティブで、サーバーで動くコードとブラウザで動くコードを書き分けます。Client Componentに渡せる値と、サーバー専用のモジュールをクライアントに含めない方法も分かります。

このページの内容

Server Component

src/lib/catalog.server.ts
import '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.tsx
import { 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.tsx
import { 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.tsx
import 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

bash
npm install server-only
vite 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-references

server-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のフォームが向いています。書き方は検索フォームを見てください。

k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2