Skip to main content

Interactive Islands

Overview

Root.js supports Islands-based Architecture for adding interactivity to components through the use of custom elements.

You can define custom elements by adding a file named by the element's tag in the elements/ folder, and Root.js will automatically inject that file to the rendered HTML when it detects usage on a page.

The way it works is:

  1. The page is rendered from TSX to HTML

  2. Root.js scans the rendered HTML for any custom elements used

  3. If a matching custom element file is found in elements/**/<tag>.ts, Root.js automatically adds that dependency to the page

Creating custom elements

Many popular frameworks have ways of rendering custom elements (e.g. Preact, Svelte, Vue). In the example below, we'll create a custom element using vanilla ts (without any framework).

Start by creating a file at elements/<tag>.ts:

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 in your route's Page component, you can freely use the custom element without having to add any import statements; Root.js will automatically detect its usage and insert the dependency into the page.

tsx
// @/routes/index.tsx

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

Rehydrating Preact components

While Root.js uses Preact under the hood to render TSX files to HTML, the framework does not output any Preact client-side code to the browser by default.

If you do wish to use Preact for client-side interactivity, we recommend creating a custom element to handle the rehydration of the server-rendered components.

Start by creating a custom element to handle the rehydration, e.g. elements/root-island.tsx:

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

Next, add a component in a folder called islands/ and use the <root-island> component to rehydrate the component:

// @/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>
  );
};

In your route, you should be able to call your island component and it should automatically be rehydrated on the client side.

// @/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: