zfb
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Islands

作成 2026年6月24日Takeshi Takatsudo

クライアントでインタラクティブなコンポーネントを "use client" でマークすると、zfb がブラウザでハイドレートします。

zfb のページはデフォルトで静的 HTML にレンダリングされます。Islands(島) はその抜け道です。ブラウザに JavaScript を配信してクライアントでハイドレートする小さなコンポーネントで、ページの残りの部分はプレーンな HTML のままに保たれます。

メンタルモデルは単純です。ページの大部分は静的なドキュメントです。いくつかのインタラクティブな要素(カウンター、検索ボックス、テーマ切り替え)は、そのドキュメントに埋め込まれた島であり、単一の共有バンドルからブラウザでハイドレートされます。

島とは何か

.tsx ファイルの先頭に "use client" ディレクティブを追加します。

"use client";

import { useState } from "preact/hooks";

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

この 1 つのディレクティブが opt-in のすべてです。これを持たないファイルは純粋なサーバーコンポーネントです。ビルド時に一度レンダリングされるだけで、ブラウザには決して届きません。

zfb-example-blog のスタンドアロンリポジトリ にある theme-toggle.tsx コンポーネントは、典型的な実例です。localStoragematchMedia を読み取り、自身の状態を管理し、アクティブなテーマを document.documentElement.dataset.theme にミラーします。鍵となるパターンは、初回ペイントでは決定論的で SSR セーフなデフォルトをレンダリングし、その後 useEffect 内でユーザーの設定に同期する点です。

"use client";

import { useEffect, useState } from "preact/hooks";

type Theme = "light" | "dark";

export default function ThemeToggle() {
  // 決定論的で SSR セーフなデフォルト。実際の設定は useEffect で適用される。
  const [theme, setTheme] = useState<Theme>("light");

  useEffect(() => {
    const saved = window.localStorage.getItem("theme");
    if (saved === "light" || saved === "dark") setTheme(saved);
  }, []);

  const next: Theme = theme === "dark" ? "light" : "dark";
  return (
    <button
      type="button"
      aria-pressed={theme === "dark"}
      onClick={() => setTheme(next)}
    >
      {theme === "dark" ? "Light mode" : "Dark mode"}
    </button>
  );
}

N 個の島に対して esbuild が生成するもの

3 つの島(CounterThemeToggleSearchBox)を持つプロジェクトの場合、islands のビルドステップは 1 つの共有バンドル を出力します。

dist/assets/islands.js

すべての島は単一の esbuild エントリポイントへ静的にインポートされるため、共有バンドルにはすべての島コンポーネントが含まれます。その後 ProductionAssetPipeline が、最終出力を書き出す前にコンテンツハッシュ付きのファイル名へとリネームします。

dist/assets/islands-<hash>.js

いずれかの島が内部で動的な import() を使っている場合、esbuild はそれと並べてコード分割チャンクも出力することがあります。

dist/assets/islands-chunk-<hash>.js

バンドルは各島を静的なマーカー名で登録し、モジュールの末尾で mountIslands() を呼び出して、ページ上のすべての [data-zfb-island] 要素をハイドレートします。

ProductionAssetPipeline がハッシュ化の単一の真実の源です — バンドラはまず安定した islands.js を書き出し、パイプラインがハッシュ付きへのリネームを行い、出力された HTML 内の注入されたスクリプト URL を書き換えます。下流の処理がファイル名を推測する必要はありません。

島がどうロードされるか

レンダリング後、各島のサーバーレンダリングされた HTML は、メタデータを持つ <div> でラップされます。

<div data-zfb-island="ThemeToggle"
     data-props="{}">
  <!-- server-rendered island HTML -->
  <button type="button" aria-pressed="false">Dark mode</button>
</div>

data-when 属性はハイドレーションのタイミングを制御し、デフォルト以外のタイミングを要求したときにのみ出力されます。"load" タイミング(即座にハイドレート)がデフォルトで、data-when 属性は生成されません。"visible""idle""media" 戦略はそれぞれ data-when 属性を出力します。"media" はさらに CSS クエリ文字列を持つ data-media 属性も出力します。

<!-- visible timing -->
<div data-zfb-island="SearchBox" data-props="{}" data-when="visible"></div>

<!-- idle timing -->
<div data-zfb-island="Counter" data-props="{}" data-when="idle"></div>

<!-- media timing — (max-width: 768px) が最初にマッチしたときにハイドレート -->
<div data-zfb-island="MobileMenu" data-props="{}" data-when="media" data-media="(max-width: 768px)"></div>

少なくとも 1 つの島を含むビルドには、1 つの <script> タグ がプロジェクト全体で <head> に注入されます。

<script type="module" src="/assets/islands-<hash>.js"></script>

共有 islands バンドル(islands-<hash>.js)はすべての島コンポーネントを含みます。スキャナが発見した静的なマーカー名(data-zfb-island 属性の値)で各島を登録し、その後 mountIslands() を呼び出します。これはページ上のすべての [data-zfb-island] 要素を走査し、シリアライズされた data-props を読み取り、既存のサーバーレンダリング済み DOM に対して hydrate() を呼び出します。

共有バンドルモデルの帰結

すべての島が単一のファイルにまとめてバンドルされるため、次のことが言えます。

  • いずれかの島を持つすべてのページが、全島のコードを読み込みます。 単一の islands-<hash>.js バンドルには、プロジェクト内のすべての島コンポーネントが含まれます。ThemeToggle しか使わないページでも、CounterSearchBox のコードをダウンロードします。これは、ページ単位の読み込み粒度を、よりシンプルなビルドパイプラインとページをまたいだ優れたキャッシュ効率と引き換えにするものです。

  • 島のないページにはスクリプトが入りません。 プロジェクトのページに "use client" コンポーネントが含まれない場合、ビルドパイプラインは <script> の注入を完全にスキップします。完全に静的なページには JavaScript が 1 バイトも届きません。

  • いずれかの島の変更が、単一のバンドルを再ハッシュします。 いずれかの島コンポーネントを追加または変更すると新しいコンテンツハッシュが生成され、それがすべてのページについてブラウザのキャッシュされたバンドルを無効化します。トレードオフは、1 つのキャッシュエントリがすべての島をカバーすることです。バンドルが一度キャッシュされれば、プロジェクト内のすべてのページが同じキャッシュヒットの恩恵を受けます。

フレームワークの選択

zfb は島について 2 つのフレームワークをサポートしています。

Config valueRuntime
"preact"(デフォルト)Preact + preact/jsx-runtime
"react"React 18 + react-dom/client

zfb.config.ts で一度だけ設定します。

export default {
  framework: "preact", // または "react"
};

これは プロジェクト全体の設定 です。プロジェクトごとに 1 つのフレームワークです。バンドラ(crates/zfb-islandsFrameworkKind enum)が、その選択を JSX 変換オプション(--jsx-import-source)と共有バンドルに埋め込まれるフレームワーク固有のハイドレーション接着コードに通します。同じプロジェクト内で Preact の島と React の島を混在させることはできません。

zfb は Vue・Svelte・Solid をサポートしていません。FrameworkKind enum は意図的に 2 バリアントの enum であり、プラグインポイントではありません。別のフレームワークが必要な場合は、以下の抜け道が一般的なケースをカバーします。

2 つのアダプタがどう動作するかの詳細は Framework adapters を参照してください。

島ではないクライアント JS のための抜け道

島はステートフルな UI コンポーネントをカバーします。それ以外のクライアントサイド JavaScript のニーズには、標準的な HTML の仕組みを直接使ってください。

インラインスクリプト — ページの TSX やレイアウトに直接 <script> タグを書きます。

export default function Layout({ children }) {
  return (
    <html>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light';`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

これは、スタイルシートのパース前に実行されなければならない同期的なハイドレーション前の処理(FOUC 防止、テーマ初期化、アナリティクスのセットアップ)に適したツールです。

外部スクリプトpublic/ または CDN の任意の .js ファイルを参照します。

<script src="/scripts/analytics.js" defer />
<script src="https://cdn.example.com/lib.js" defer />

カスタムビルドステップ — 追加の TypeScript モジュールをバンドルする必要がある場合は、ビルドパイプラインに別の esbuild または Rollup のステップを追加し、その出力を <script src> から参照します。zfb は "use client" でない任意のモジュールを自動バンドルしません。自動で実行されるのは islands パイプラインだけです。

できないこと: 通常の("use client" でない).ts.tsx モジュールをページからインポートして、そのブラウザ側のコードがクライアントに届くことを期待すること。ディレクティブを持たないモジュールはサーバー専用です。SWC がビルド時にコンパイルして評価し、そのバイトは出力に一切含まれません。

島を使わないほうがよいとき

島は JavaScript を配信します。それにはコストがあります。追加する前に、それなしで問題を解決できないか自問してください。

DOM のクラス入れ替えトグル(アコーディオン、ディスクロージャーメニュー、表示・非表示パネル)は、数行のプレーンな CSS や小さなインラインの <script> で解決できることがよくあります。ネイティブの <details> / <summary> 要素は JavaScript なしでアコーディオンの挙動を扱います。

<details>
  <summary>Frequently asked question</summary>
  <p>The answer goes here.</p>
</details>

CSS のみのアプローチ(:target:checked + <label>@starting-style)は、かつて JavaScript を必要とした多くのインタラクティブなパターンを扱えます。

島が適したツールとなるのは次の場合です。

  • コンポーネントが、単一のインタラクションを超えて存続しなければならない状態を持つ場合(例: カート、ユーザーセッション、複数ステップのフォーム)。

  • コンポーネントがビルド時には利用できないブラウザ API に依存する場合(canvasWebGLgetUserMedia・リアルタイムデータ)。

  • そうでなければコンポーネントを 2 回(サーバーレンダリング用に 1 回、クライアント用に 1 回)書いて手動で同期し続けることになる場合。

正直な答えが「クリックでクラスを 1 つ切り替えたいだけ」なら、まず CSS か小さなインラインスクリプトに手を伸ばしてください。「2 回書くことになるステートフルな UI」に島を、というのが正しい判断基準です。

パイプラインの形についてさらに詳しくは Build pipelineBuild engine を参照してください。

Revision History

Takeshi Takatsudo作成: 2026-06-25T05:17:25+09:00更新: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.