Ion Documentation
0.1.0-beta.1 README

Web Components engine

Build components without leaving the platform behind.

Ion gives Custom Elements a compact reactive model for rendering, state, properties, lifecycle, events, plugins and hooks.

Small surface

Extend Component, return an html template, and add only the decorators you need.

Platform-native

Components remain real custom elements with a real shadow root, native attributes, events and DOM.

Lifecycle-aware

Render scheduling, reconnect behavior, and owned event cleanup are part of the component model.

01 · Start

Install

pnpm add @gottheflag/ion

Ion exposes separate entry points so optional subsystems stay explicit and bundlers can remove unused code.

02 · Component

Your first component

import {
	Component,
	Ion
} from "@gottheflag/ion";

import {
	state
} from "@gottheflag/ion/decorators";

import {
	html
} from "@gottheflag/ion/template";

@Ion.create("x-counter")
class Counter extends Component {
	@state
	private count = 0;

	protected override created() {
		this.on(
			"click",
			"#increment",
			() => {
				this.count++;
			}
		);
	}

	protected override render() {
		return html`
			<button id="increment">
				Count: ${this.count}
			</button>
		`;
	}
}
Mental model

Changing reactive state schedules one render. Ion reconciles the returned template into the existing shadow DOM instead of replacing the whole root.

03 · Registration

Ion.create()

Register while declaring the class:

@Ion.create("x-panel")
class Panel extends Component {}

Or register later:

class Panel extends Component {}

Ion.create(
	Panel,
	"x-panel"
);

Shadow root options

@Ion.create(
	"x-dialog",
	{
		root: {
			mode: "open",
			delegatesFocus: true
		}
	}
)
class Dialog extends Component {}

04 · Lifecycle

A clean component cycle

MethodWhen it runs
created()After a real DOM connection. Runs again after a true disconnect/reconnect.
ready()After the first successful render of that component instance.
removed()After a genuine detach. Same-tick DOM moves do not create a false teardown.
adopted(oldDoc, newDoc)When the element moves to another document.
attributeChanged(...)When an observed attribute changes.
protected override created() {
	this.on(
		window,
		"resize",
		() => {
			this.requestRender();
		}
	);
}

Listeners registered through this.on() belong to the current component lifetime and are released automatically on a real detach.

05 · Rendering

Templates

protected override render() {
	return html`
		<h2>${this.title}</h2>
		<ul>
			${this.items.map(
				item => html`<li>${item}</li>`
			)}
		</ul>
	`;
}
InterpolationBehavior
string / number / bigintSerialized with HTML-significant characters escaped.
null / undefined / booleanRenders nothing.
nested htmlRendered recursively.
arraysRecursively serialized using the same contract.
object / function / symbol / DOM NodeRejected instead of silently stringified.
Context still matters.

Escaping protects serialization boundaries. Values used in URL-like or otherwise security-sensitive attributes should still be validated for that semantic context.

06 · Reactivity

@state

Use state for internal reactive values. A changed value requests a render; writing the same value again is ignored through Object.is.

@state
private open = false;

toggle() {
	this.open = !this.open;
}

07 · Public state

@property()

@property({
	type: Number,
	reflect: true,
	render: true
})
value = 0;

@property({
	attribute: "busy",
	type: Boolean
})
loading = false;
OptionDefaultPurpose
typeStringString, Number, or Boolean conversion from attributes.
reflecttrueReflect property writes to the matching attribute.
rendertrueRequest a render after a changed value.
attributeproperty nameOverride the HTML attribute name.

08 · Events

Own listeners and emit typed events

Delegated event

this.on(
	"click",
	"[data-remove]",
	(event, matched) => {
		matched.remove();
	}
);

Explicit target

this.on(
	window,
	"keydown",
	event => {
		if (event.key === "Escape") {
			this.close();
		}
	}
);

Typed custom event

declare global {
	interface HTMLElementEventMap {
		"counter:change":
			CustomEvent<{
				value: number;
			}>;
	}
}

this.emit(
	"counter:change",
	{
		value: this.count
	}
);

Custom events bubble and cross the shadow boundary by default. Override bubbles, composed, or cancelable when needed.

09 · DOM state

Queries and watchers

@query

@query("#search")
private search:
	HTMLInputElement | null = null;

@query(".row", {
	all: true
})
private rows:
	HTMLElement[] = [];

Query properties are refreshed after every render. They are snapshots of the current rendered DOM.

@watch

@watch("mode")
private modeChanged(
	oldValue: string | null,
	newValue: string | null
) {
	console.log(
		oldValue,
		newValue
	);
}

10 · Styling

Constructable stylesheets

Define styles as a CSS string, a CSSStyleSheet, or an array containing both.

protected static override styles = `
	:host {
		display: block;
	}

	button {
		border-radius: 0.5rem;
	}
`;

11 · Small tools

Cache and once

@cache

Cache a getter or zero-argument method on first evaluation. Parameterized methods are rejected.

@cache
get expensiveValue() {
	return compute();
}
@once

Run a method at most once for each instance. Later calls are skipped.

@once
initialize() {
	connect();
}

12 · Extension

Plugins

Plugins are optional. Import the plugin entry point only when the application needs the plugin runtime.

import {
	Plugin
} from "@gottheflag/ion/plugin";

class MetricsPlugin
	extends Plugin<
		Panel,
		{
			label: string;
		}
	> {

	get label() {
		return this.options.label;
	}

	protected afterRender() {
		console.log(
			"rendered",
			this.host
		);
	}
}

Panel.use(
	MetricsPlugin,
	{
		label: "dashboard"
	}
);

Read a plugin API from an instance with this.plugin(MetricsPlugin). Plugin lifecycle callbacks cover creation, ready, render boundaries, attribute changes, adoption and removal.

13 · Instrumentation

Hooks

Hooks observe lifecycle phases globally or for one component class. They are best suited to instrumentation and cross-cutting extensions.

import {
	hok
} from "@gottheflag/ion/hook";

const handle =
	hok.afterFor(
		Panel,
		"render",
		host => {
			console.log(
				"rendered",
				host
			);
		}
	);

// later
handle.off();

Phases include define, init, create, render, ready, remove, attribute change, adoption, event emission, and plugin installation.

14 · Component API

Helpers

APIPurpose
this.$("#id")First matching element inside the shadow root.
this.$$(".row")All matching elements inside the shadow root.
this.attr("name")Read an attribute.
this.attr("name", value)Write, toggle, or remove an attribute.
this.requestRender()Queue one connection-safe render.
this.on(...)Register an owned event listener.
this.emit(...)Dispatch a custom event.

15 · Reference

Import map

Entry pointMain exports
@gottheflag/ionComponent, Ion, utility helpers
@gottheflag/ion/templatehtml, template types
@gottheflag/ion/decoratorsstate, property, query, watch, on, cache, once
@gottheflag/ion/pluginPlugin, withDefaults
@gottheflag/ion/hookhok, hook utilities and types
Keep the model simple

A component owns its state, renders a template, reacts to platform events, and releases lifetime-owned work when it truly leaves the DOM. Reach for plugins or hooks when a concern really crosses component boundaries.

Back to the first component