zfb
GitHub repository

Type to search...

to open search from anywhere

Island

Created Jun 24, 2026Takeshi Takatsudo

Wrap a component as a client-hydrated island using the Island JSX wrapper.

The <Island> wrapper

Import Island from "@takazudo/zfb" and wrap any component that should hydrate in the browser. Everything else stays server-rendered HTML.

import { Island } from "@takazudo/zfb";
import Counter from "./Counter";

export default function Page() {
  return (
    <main>
      <h1>My Page</h1>
      <Island when="visible">
        <Counter />
      </Island>
    </main>
  );
}

At SSR time the <Island> wrapper emits a <div data-zfb-island="Counter" data-when="visible"> marker with the server-rendered child inside. The client runtime (@takazudo/zfb-runtime) queries these markers and mounts the component when the when condition fires.

IslandProps

interface IslandProps {
  when?: When;
  media?: string;
  ssrFallback?: VNode;
  children?: VNode;
}
  • when — hydration strategy. Defaults to "load". See Hydration strategies below.

  • media — CSS media query string for when="media" islands. The island hydrates when this query first matches (including later viewport changes via resize or rotation). Required when when="media"; ignored (with a dev warning) for other strategies.

  • ssrFallback — enables SSR-skip mode (equivalent to Astro's client:only). When provided, the heavy children are not evaluated server-side; ssrFallback is rendered in their place and the client swaps in the real component on hydration.

  • children — the component to hydrate.

Hydration strategies

The when prop accepts four values, all shipped today:

ValueBehaviour
"load"Hydrate immediately when the page's JavaScript runs. This is the default when when is omitted.
"visible"Hydrate when the island's root element enters the viewport (IntersectionObserver, threshold 0). Cheapest deferral for off-screen content.
"idle"Hydrate during the browser's next idle callback. Falls back to setTimeout(0) on platforms without requestIdleCallback.
"media"Hydrate when a CSS media query first matches (window.matchMedia). Requires the companion media prop. Continues to respond to later viewport changes (resize, orientation). Degrades to immediate hydration when matchMedia is unavailable. Note: SSR-skip (ssrFallback) islands ignore when entirely — they render immediately.
// Hydrate only on narrow viewports (e.g. mobile menu)
<Island when="media" media="(max-width: 768px)">
  <MobileMenu />
</Island>

SSR-skip mode

Pass ssrFallback to skip server rendering the heavy child entirely:

import { Island } from "@takazudo/zfb";
import HeavyChart from "./HeavyChart";

export default function Page() {
  return (
    <Island ssrFallback={<div>Loading chart…</div>}>
      <HeavyChart data={data} />
    </Island>
  );
}

The server emits <div data-zfb-island-skip-ssr="HeavyChart" data-when="load">…fallback…</div>. On hydration the client runtime renders HeavyChart into the placeholder.

VNode type export

VNode, VNodeArray, and VNodeObject are exported as public types from "@takazudo/zfb":

import type { VNode, VNodeArray, VNodeObject } from "@takazudo/zfb";

These structural types are framework-agnostic. VNode includes a bare object member (matching Preact's own ComponentChild design), so Preact's ComponentChildren, VNode<Props>, JSX.Element, and JSX.Element[] are all directly assignable at Island input boundaries — children and ssrFallback — with no as unknown as casts:

import type { ComponentChildren } from "preact";
import { Island } from "@takazudo/zfb";

// Forwarding ComponentChildren as children — no cast needed
function Wrapper({ children }: { children: ComponentChildren }) {
  return <Island when="load">{children}</Island>;
}

// Forwarding ComponentChildren as ssrFallbackno cast needed
function SkipWrapper({ fallback }: { fallback: ComponentChildren }) {
  return (
    <Island ssrFallback={fallback}>
      <HeavyWidget />
    </Island>
  );
}

Annotating slots as ComponentChildren (return direction)

When a slot variable holds the return value of <Island>, annotate its type as ComponentChildren or JSX.Element — not as IslandElement. IslandElement is intentionally a narrow structural type that requires props.children, and VNode<{}> from Preact is NOT assignable to it by design. Use a Preact-native type for the slot instead:

import type { ComponentChildren } from "preact";

function Page() {
  const slot: ComponentChildren = (
    <Island when="visible">
      <Counter />
    </Island>
  );
  return <main>{slot}</main>;
}

Name-collision note for Preact consumers

If a file already has import { VNode } from "preact", use a qualified import to avoid the name clash:

import type { VNode as ZfbVNode } from "@takazudo/zfb";

Exported constants and helpers

The following are exported from "@takazudo/zfb" alongside Island:

  • HYDRATE_MARKER_ATTR — the data-zfb-island attribute name.

  • SKIP_SSR_MARKER_ATTR — the data-zfb-island-skip-ssr attribute name.

  • ANONYMOUS_COMPONENT_NAME — fallback name used when a wrapped child's identity cannot be determined.

  • resolveWhen(when: unknown): When — validates and normalises a when value. Unknown inputs fall back to "load" (with a warning in development).

"use client" directive

zfb also supports a "use client" file-level directive as an alternative authoring style. A component file whose first statement is the literal string "use client" is treated as an island entry by the build scanner (crates/zfb-islands). The esbuild-backed bundler compiles it to ESM separately, with its own dependency graph.

"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>
  );
}

Import the island from a server component or page module. The build pipeline wires the hydration bootstrap automatically.

For the broader story on islands architecture and when to reach for one, see Islands.

Footgun: do not self-wrap an island

Nested island markers mis-hydrate

Do not place an <Island> wrapper inside a component's own render output. The runtime discovers all island markers in the DOM and mounts each one — if a marker is nested inside another, two independent framework instances try to own the same DOM subtree, causing conflicts.

Wrong<Island> inside the component itself:

// Counter.tsx — do NOT do this export default function Counter() { return ( <Island when="idle"> <InnerWidget /> </Island> ); }

Right — apply <Island> at the call site:

// Page.tsx <Island when="idle"> <Counter /> </Island>

In development the runtime emits a console.warn naming the offending component when it detects this pattern.

Revision History

Takeshi TakatsudoCreated: 2026-06-25T05:17:25+09:00Updated: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.