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:
-
The page is rendered from TSX to HTML
-
Root.js scans the rendered HTML for any custom elements used
-
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:
// @/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.
// @/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:
// @/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 />
);
}