DAVAUX
Alpha

Islands

This page covers the island hydration runtime and its advanced controls. If you are looking for how to write and use reactive components day-to-day, start with Reactive Components.

How hydration works

When Davaux renders a reactive component server-side it emits a wrapper element containing:

  • The pre-rendered HTML (visible immediately, no layout shift)
  • A data-props attribute with the JSON-serialised props
  • A data-island attribute naming the component
<div data-island="Counter" data-props='{"initial":5}' style="display:contents">
  <div class="counter">
    <button></button><span>5</span><button>+</button>
  </div>
</div>

The wrapper uses display: contents — it is completely layout-transparent and never breaks flexbox, grid, or any other layout context.

The client bundle picks up each [data-island] element and calls hydrate() on it, restoring full signal reactivity. Because the initial HTML is already present, there is no loading flash. The component becomes interactive as soon as the tiny JS bundle evaluates — typically within a few dozen milliseconds.

Disposing an island

Every hydrated island runs inside its own reactive scope — the same createRoot mechanism davaux/client exposes directly (see Signals & Store). If you remove an island's element from the DOM yourself — closing a modal, building your own client-side router — call disposeIsland() first so its effects stop running and any onCleanup() handlers (timers, WebSocket connections, createResource polling) actually fire:

import { disposeIsland } from 'davaux/client'

const el = document.querySelector('[data-island="Modal"]')
if (el) {
  disposeIsland(el)
  el.remove()
}

disposeIsland() is a no-op if the element was never hydrated, or has already been disposed — safe to call defensively.

Props must be JSON-serialisable

Since props are embedded as a JSON string in data-props, they must round-trip through JSON.stringify / JSON.parse. Supported types:

  • Strings, numbers, booleans, null
  • Plain objects (including nested)
  • Arrays

Not supported:

  • Date objects — serialize to ISO string yourself: date.toISOString()
  • Functions
  • Class instances
  • undefined values (omit the key instead)
// OK
<Counter
  name="Alice"
  count={42}
  tags={['a', 'b']}
  config={{ dark: true }}
/>

// Not OK — will throw at build time or produce wrong results
<Counter
  createdAt={new Date()}         // serialize to string first
  onClick={() => console.log()}  // functions cannot be serialized
/>

The island() function

The reactive() wrapper handles most cases automatically — the build pipeline detects the component and generates the island marker at build time. When you need explicit control over the island ID or want to wrap a component that isn't auto-detected, use island() directly:

// src/components/Counter.tsx
/** @jsxImportSource davaux/client */
import { island } from 'davaux'
import { createSignal } from 'davaux/client'

function Counter({ initial = 0 }: { initial?: number }) {
  const [count, setCount] = createSignal(initial)
  return (
    <div class="counter">
      <button onClick={() => setCount(count() - 1)}>−</button>
      <span>{() => count()}</span>
      <button onClick={() => setCount(count() + 1)}>+</button>
    </div>
  )
}

export default island(Counter, 'my-counter')

The second argument is an optional stable ID. If omitted, island() uses the function name. Stable IDs are useful when minification would otherwise rename the function.

island() and reactive() produce the same runtime output — the data-island wrapper. The difference is that island() is explicit and runs at call time, while reactive() delegates to the build pipeline.

Explicit src/islands/ directory

If you prefer a dedicated directory for island components, src/islands/ is unconditionally scanned — every file in it is included in the client bundle regardless of what it imports. The pragma and reactive() are still needed for correct TypeScript types, but the from 'davaux/client' import is not required for detection.

src/
  islands/
    Counter.tsx   ← always bundled
    Dropdown.tsx  ← always bundled
  components/
    Header.tsx    ← server-only, not bundled
    CartBadge.tsx ← bundled because it imports from 'davaux/client'

The auto-detection approach (any file under src/ that imports davaux/client) is the recommended default since it lets components live beside the routes that use them. src/islands/ is useful when you want an explicit inventory of everything that ships to the client.

Islands from plugins

Packages can ship island components in two ways.

islandsDir — declare an absolute path to a directory. All files in it are included unconditionally, the same as the local src/islands/:

import { resolve } from 'node:path'
import type { DavauxPlugin } from 'davaux/config'

export function myPlugin(): DavauxPlugin {
  return {
    name: 'my-plugin',
    islandsDir: resolve(import.meta.dirname, 'islands'),
  }
}

srcDirs — declare source directories to scan for reactive components (auto-detected via from 'davaux/client'). Use this when reactive and server-only components share the same dist directory:

export function myPlugin(): DavauxPlugin {
  return {
    name: 'my-plugin',
    srcDirs: [resolve(import.meta.dirname, 'components')],
  }
}

From the app's perspective both approaches are identical — plugin islands work exactly like local reactive components:

import SomeWidget from '@my-org/my-plugin/islands/SomeWidget.tsx'

export default definePage(() => (
  <main>
    <SomeWidget />
  </main>
))

See Plugins for the full plugin authoring guide.