Preact Counter
Hydrates on interaction
Learn how Avalon's islands system works and how to control hydration.
An island is an interactive component that hydrates on the client. Everything outside an island is static HTML — no JavaScript, no hydration overhead.
The term comes from the "islands architecture" pattern: a sea of static HTML with interactive islands scattered throughout.
Islands do not share framework context or a built-in store. To update another island after hydration, use DOM CustomEvents (not module-level singleton stores that can leak during SSR). See Cross-Island State.
island propTo make a component an island, import it and add the island prop:
import Counter from '../components/Counter.tsx';
export default function Page() {
return (
<div>
<Counter island={{ condition: 'on:client' }} />
</div>
);
}
The condition field controls when the island hydrates. Set ssr: false when the component must not run on the server at all.
Live
Hydrates on interaction
Hydrates on interaction
Click a counter. That island loads its JavaScript. The rest of the page stays static.
A normal island is server-rendered HTML that the browser later hydrates. A client-only island is an empty <avalon-island> placeholder; the browser imports the component and mounts it.
<Chart island={{ ssr: false }} userId={user.id} />
<Map island={{ ssr: false, condition: 'on:visible' }} />
Use this when the component needs browser APIs during render, embeds a third-party widget, or has no useful SSR output. Props are still serialized. There is no SSR HTML and no no-JS fallback for that component. Combine with condition to defer the mount.
The empty placeholder is display: block so observers can attach. Give it a min-height (or wrap it) if you use on:visible and need a stable layout box before mount.
Do not write <Chart client:only /> — Avalon has no client:* attributes.
on:clientHydrates immediately when the page loads. Use this for components that need to be interactive right away.
<Counter island={{ condition: 'on:client' }} />
on:visibleHydrates when the island scrolls into the viewport. Uses IntersectionObserver under the hood. Ideal for below-the-fold content.
<Chart island={{ condition: 'on:visible' }} />
on:interactionHydrates on the first user interaction (click or hover). The component renders its SSR HTML immediately, then becomes interactive on demand. Great for forms, dropdowns, and other UI that doesn't need to be interactive until the user engages.
<Dropdown island={{ condition: 'on:interaction' }} />
on:idleHydrates when the browser is idle using requestIdleCallback. Use this for low-priority widgets that shouldn't compete with critical rendering.
<Analytics island={{ condition: 'on:idle' }} />
media:Hydrates only when a CSS media query matches. Useful for components that only exist at certain breakpoints.
<MobileMenu island={{ condition: 'media:(max-width: 768px)' }} />
Islands are auto-discovered — any component can become an island by adding the island prop. JSX components require the appropriate pragma for their renderer:
/** @jsxImportSource preact */
import { useState } from 'preact/hooks';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Avalon pages use Preact JSX, but islands can be written in any supported framework. To make TypeScript happy when you use a Vue or Svelte component in a Preact page, Avalon auto-generates sidecar .d.ts files next to each island component.
For example, when you import Counter.vue in a page file, TypeScript can't parse .vue files — so it looks for Counter.d.vue.ts instead. Avalon generates this file automatically with a Preact-compatible type signature, including the island prop.
This works transparently for Vue and Svelte because TypeScript has no built-in understanding of .vue or .svelte files and falls back to the sidecar.
Your tsconfig.json needs allowArbitraryExtensions: true for this to work.
For frameworks that use .ts or .tsx files (Lit and Qwik), sidecars don't help — TypeScript can parse these files natively, so it reads the source directly and ignores any sidecar declaration file.
The problem is that these frameworks export types incompatible with Preact JSX. Qwik's component$() returns a function with a different signature than Preact expects, and Lit components are classes, not functions.
Each of these integrations provides a defineXxxIsland() wrapper — a zero-cost identity function that returns the component unchanged at runtime but provides Preact-compatible types:
// Qwik
import { defineQwikIsland } from '@useavalon/qwik/island';
export default defineQwikIsland(MyQwikComponent);
// Lit
import { defineLitIsland } from '@useavalon/lit/island';
export default defineLitIsland(MyLitComponent);
These wrappers live in a separate island.ts file (not mod.ts) to avoid pulling server-only code into the browser. See the Lit and Qwik framework pages for full examples.