Skip to main content
Framework

Interactive islands

Overview

Root.js pages are static HTML by default. To add interactivity, Root.js uses an islands architecture built on custom elements: only the interactive parts of a page load JavaScript.

It works like this:

  1. The page is rendered from TSX to HTML.

  2. Root.js scans the HTML for custom elements.

  3. For each one with a matching file in elements/**/<tag>.ts, Root.js adds that file to the page.

Create a custom element

You can write custom elements in plain TypeScript, or with a framework that compiles to them (e.g. Preact, Svelte or Vue). This example uses plain TypeScript.

Create a file named after the element's tag in elements/:

ts
// @/elements/root-counter.ts

declare module 'preact' {
  namespace JSX {
    interface IntrinsicElements {
      'root-counter': preact.JSX.HTMLAttributes;
    }
  }
}

class RootCounter extends HTMLElement {
  value = 0;

  connectedCallback() {
    const button = this.querySelector('button');
    const valueEl = this.querySelector('.value');
    if (button && valueEl) {
      button.addEventListener('click', () => {
        this.value += 1;
        valueEl.textContent = String(this.value);
      });
    }
  }
}

if (!customElements.get('root-counter')) {
  customElements.define('root-counter', RootCounter);
}

Then use the element in any page. There's nothing to import: Root.js detects it and adds the script.

tsx
// @/routes/index.tsx

export default function Page() {
  return (
    <root-counter>
      <button>Count</button>
      <div className="value">0</div>
    </root-counter>
  );
}

Hydrate Preact components

Root.js renders TSX with Preact on the server, but doesn't send Preact to the browser. To make a Preact component interactive, wrap it in a custom element that hydrates it on the client.

First, create the element that does the hydrating:

tsx
// @/elements/root-island.tsx

import {hydrate} from 'preact';

declare module 'preact' {
  namespace JSX {
    interface IntrinsicElements {
      'root-island': preact.JSX.HTMLAttributes & {
        component: string;
        props?: string;
      };
    }
  }
}

const islands: Record<string, any> = {};
const islandsModules = import.meta.glob('/islands/**/*.tsx');
Object.entries(islandsModules).forEach(([moduleId, loader]) => {
  const componentName = moduleId.split('/')[2];
  islands[componentName] = loader;
});

class RootIsland extends HTMLElement {
  connectedCallback() {
    const componentName = this.getAttribute('component');
    if (!componentName) {
      return;
    }
    const propsAttr = this.getAttribute('props');
    const props = propsAttr ? JSON.parse(propsAttr) : {};
    this.rehydrate(componentName, props);
  }

  async rehydrate(componentName: string, props: any) {
    const loader = islands[componentName];
    if (loader) {
      const module = await loader();
      const Island = module[componentName];
      if (Island && Island.Component) {
        hydrate(<Island.Component {...props} />, this);
      }
    }
  }
}

if (!window.customElements.get('root-island')) {
  window.customElements.define('root-island', RootIsland);
}

Then add components to an islands/ folder, wrapped in <root-island>:

tsx
// @/islands/Counter.tsx

import {useState} from 'preact/hooks';

export function Counter(props) {
  return (
    <root-island component="Counter" props={JSON.stringify(props)}>
      <Counter.Component {...props} />
    </root-island>
  );
}

Counter.Component = (props) => {
  const [value, setValue] = useState(0);

  function incr() {
    setValue((current) => current + 1);
  }

  return (
    <div className="counter">
      <button onClick={() => incr()}>Count</button>
      <div>{value}</div>
    </div>
  );
};

Use the component like any other. It's rendered on the server, then hydrated in the browser.

tsx
// @/routes/index.tsx

import {Counter} from '@/islands/Counter';

export default function Page() {
  return <Counter />;
}
1
2
3
4
5
6
7
8
9
10
11
12
Breakpoint: