@li3/web
v0.2.41
Published
Downloads
1,136
Readme
@li3/web
Primitives for general-purpose web applications in the modern web. A reactive, template-driven component library built on web standards (custom elements, templates, shadow DOM, CSS modules) with a concise programming interface.
Public API
Reactive values and references:
ref(value) Creates a reactive wrapper to a value (like a signal)
computed(fn) Creates a computed property that updates when its dependencies change (e.g. other computed or refs)
templateRef(refName: string) Creates a ref and finds an element with a `ref={refName}` attribute in the view
hook(value) Creates a reactive wrapper using React style hooks (e.g. const [value, setValue] = hook(initialValue))
watch(ref, callback) Watches a reactive source and calls the callback when it changes
effect(fn, effectFn) Runs a function and tracks its dependencies, re-running the effectFn when they change (wraps fn in computed)
reactive(object, effect) Creates a reactive version of an object that triggers an effect when it changes
nextTick() Resolves after the pending reactive update queue flushesLifecycle Hooks:
onInit(fn) Called just before the component is initialized
onDestroy(fn) Called just before the component is destroyed
onUpdate(fn) Called just before the component inputs have changed (i.e. one or more props have changed)
onCleanup(fn) Registers per-instance cleanup for resources created during setup
Component setup definitions:
defineProp(name, options) Defines a prop for a component with the given name and options (type, default value, etc.)
defineEvent(name) Defines a custom event that the component can emit with the given nameComponent definition:
defineComponent(name, options) Defines a custom element with the given name and options (template, shadowDom and setup function)
mount(targetElement, options) Mounts a component to a target element with the given options
unwrap(object) Unwraps a reactive reference or computed property to get its underlying value
load(href) Loads one or more components (as HTML) from a source and registers the as a custom elementInternals:
compare(a, b) Compares two values for equality, handling reactive references and computed properties
canBeObserved(object) Checks if an object can be observed for reactivity (internal use only)
isRef(x) Check if a value was created with ref()Extensions:
use(rule) Add a new node and attribute matcher. `use({ match(node, attribute, value) {}, exec(node, attribute, value, context) {} })`Set window.name to debug in any page to attach component context to components.
Complete Guide
The rest of this document is a complete, example-driven description of everything @li3/web can do.
It is written to be the only documentation needed to design and build applications with this framework.
Table of contents
- Mental model
- Getting started
- Template syntax reference
- Template expressions: rules and semantics
- Declaring components in HTML
- Full component examples
- Reactivity in depth
- Props
- Events between components
- Lifecycle hooks
- Loading components at runtime
- Programmatic mounting
- The extension system
- Feature flags and debugging
- Complete syntax cheat sheet
- Gotchas and rules of thumb
1. Mental model
An application is a tree of custom elements. Each component is:
- a template (plain HTML with binding syntax),
- an optional setup function that returns the template's context (state + behavior),
- optional styles (adopted stylesheets),
- optional props (inputs) and events (outputs).
Data flows down via props (defineProp + bind-*) and up via events (defineEvent + on-*).
State is held in signals (ref, computed); templates track signal reads and update the DOM
automatically when they change.
Two ways to declare components:
- Declaratively in HTML —
<template component="my-element">in the page (auto-initialized) or in files fetched withload(href). This is the primary path. - Programmatically in JS —
mount(element, { template, setup })ordefineComponent(name, options).
2. Getting started
Import the module in a page. Auto-initialization runs shortly after module evaluation (on
DOMContentLoaded), which:
- defines every in-document
<template component="...">as a custom element, - fetches and registers every
<link rel="component" href="...">, - mounts every
<template app>in place.
<script type="importmap">
{ "imports": { "@li3/": "https://cdn.li3.dev/@li3/" } }
</script>
<script type="module">
import '@li3/web';
</script>
<template app>
<h1>{{ title }}</h1>
<button on-click="increment()">Count: {{ count }}</button>
<script setup>
import { ref } from '@li3/web';
export default function () {
const title = ref('Hello Lithium');
const count = ref(0);
const increment = () => count.value++;
return { title, count, increment };
};
</script>
</template>A standalone loader script that injects the import map and imports @li3/web is also published as
@li3/web/loader. If your page already has an import map, add "@li3/": "https://cdn.li3.dev/"
to it manually instead.
3. Template syntax reference
Templates are plain HTML. Special meaning is given to:
{{ ... }}inside text nodes,- attributes starting with
on-,bind-,attr-,class-,style-, <template>elements carrying aniforforattribute,- the plain
refattribute.
After a binding is processed, its attribute is removed from the DOM (kept when the debug feature
flag is on). Only one rule applies per attribute — the first matching rule wins.
3.1 Interpolation: {{ expression }}
Any text node containing {{ becomes reactive. Each {{ expression }} is spliced into a template
literal; the whole text node re-renders whenever any dependency of the expressions changes.
<p>Count: {{ count }}</p>
<p>{{ count }} doubled is {{ count * 2 }}</p>
<p>{{ user.name }} ({{ user.roles.join(', ') }})</p>- Expressions are arbitrary JavaScript, evaluated against the component context.
- Refs are auto-unwrapped:
{{ count }}renderscount.value. undefinedrenders as an empty string.- The entire text node is replaced on update, so put each interpolation in its own element if you
need granular structure:
<p>Hello <b>{{ name }}</b>!</p>.
3.2 Event bindings: on-<event>[.<modifier>...]="expression"
Adds an event listener. The expression is compiled to a function receiving $event.
<button on-click="increment()">+1</button>
<input on-input="query = $event.target.value" />
<form on-submit.prevent="save()">...</form>
<div on-click.self="close()">...</div>
<a href="/x" on-click.stop.prevent="navigate($event)">link</a><event>is a plain DOM event name:click,input,change,submit,keydown,update, ...- Also listens to custom events emitted by child components:
on-save="onSave($event)"— payload is in$event.detail. - Modifiers (dot-suffixed, combinable):
.stop— callsevent.stopPropagation().prevent— callsevent.preventDefault().self— handler only runs ifevent.targetis the element itself
Handlers call functions from the setup context. Direct assignment to context variables is not possible (read-only context, see §4) — call methods instead.
3.3 Property bindings: bind-<prop>[.<modifier>...]="expression"
Sets a DOM property (not an attribute). Re-evaluated whenever the expression's dependencies change.
<input bind-value="name" />
<video bind-current-time="position"></video>
<div bind-scroll-top="scrollY"></div>
<pre bind-innerhtml="highlightedCode"></pre>The property name is camelCased:
bind-scroll-top→el.scrollTop.Name mappings:
bind-innerhtml→el.innerHTML,bind-baseuri→el.baseURI,bind-class→el.className..boolmodifier — toggles a boolean attribute instead of setting a property:<button bind-disabled.bool="isSaving">Save</button> <section bind-hidden.bool="!isVisible">...</section>Object syntax for
class— when the expression source text starts with{, each key is toggled as a class name by the truthiness of its value:<div bind-class="{ active: isActive, disabled: isDisabled }"></div>Object syntax for
style— sets style properties (keys camelCased):<div bind-style="{ color: themeColor, 'font-size': size + 'px' }"></div>
This is also how you pass data into child components (see §8):
<count-trigger bind-trigger="currentValue"></count-trigger>3.4 Attribute bindings: attr-<name>[.<modifier>...]="expression"
Sets an HTML attribute (stringified). Re-evaluated on change.
<button attr-aria-label="buttonLabel">?</button>
<div attr-data-state="state" attr-title="tooltip"></div>
<input attr-aria-invalid="hasError" />.boolmodifier — toggles the attribute's presence:<button attr-disabled.bool="!isValid">Submit</button>Attribute names must match
/^[a-zA-Z_][a-zA-Z0-9\-_:.]*$/, otherwise the binding is ignored.Use
attr-*for attributes (ARIA,data-*,title, SVG attributes); usebind-*for DOM properties (value,checked,scrollTop, component props).
3.5 Class bindings: class-<name>[.<name2>...]="expression"
Toggles one or more classes based on the truthiness of the expression.
<div class-warning="count > 10">...</div>
<button class-font-bold.text-lg="counter > 2">...</button>
<li class-selected="item.id === selectedId">...</li>Multiple classes in a single attribute are separated by dots: class-a.b="x" toggles a and b.
(A hyphenated name like class-font-bold is a single class font-bold.)
For object-style class maps use bind-class="{ ... }" (§3.3).
3.6 Style bindings: style-<property>="expression"
Sets an inline style property. Re-evaluated on change.
<div style-color="themeColor">...</div>
<div style-background-image="bgUrl">...</div>
<div style-transform="'translateX(' + x + 'px)'">...</div>The property name is camelCased: style-background-image → el.style.backgroundImage.
For object style maps use bind-style="{ ... }".
3.7 Conditional rendering: <template if="expression">
Renders its content only while the expression is truthy.
<template if="count > 0">
<p>You've clicked {{ count }} times</p>
</template>
<template if="user">
<user-card bind-user="user"></user-card>
</template>- Must be a literal
<template>element with anifattribute. - The template is replaced by a comment anchor (
<!--if: expression-->); content is cloned into the DOM when the expression turns truthy and removed when it turns falsy. - Content inside is fully linked to the context (all bindings work).
- There is no
else/else-if— use two<template if>blocks with complementary conditions, or computed expressions. - DOM insertion is asynchronous (microtask-ish
setTimeout); don't query the new DOM synchronously right after changing the condition.
3.8 List rendering: <template foreach="item of items">
Renders the content once per item of an iterable.
foreach is the recommended list directive and maps to the TemplateForeach built-in rule class.
For migration compatibility, <template for="..."> is also accepted for now. It may conflict with
the proposed platform meaning of for, so migrate applications to foreach before that alias is
removed in a future release.
<ul>
<template foreach="todo of todos">
<li class-done="todo.done" on-click="toggle(todo)">{{ todo.title }}</li>
</template>
</ul>With an index, use array destructuring syntax:
<template foreach="[user, i] of users">
<tr>
<td>{{ i + 1 }}</td>
<td>{{ user.name }}</td>
</tr>
</template>The separator is
of(notin). The right side is any expression; it is wrapped inArray.from(expression || []), so arrays and iterables work, andnull/undefinedrender nothing.The template is replaced by a comment anchor (
<!--for: item of items-->).Inside the loop body,
item(and the optional index) are available as context variables. Item values are auto-unwrapped refs, so{{ todo.title }}just works.DOM reuse by position: when the array changes, existing rows are kept and their item refs are updated in place (
item.value = newItem). Rows beyond the new length are removed; new rows are cloned and appended. There is no keying /track by— reordering an array rewrites the item of each row at its index rather than moving DOM nodes.Nested loops work; inner loops shadow outer variables:
<template foreach="group of groups"> <h2>{{ group.name }}</h2> <template foreach="[member, j] of group.members"> <p>{{ j }}: {{ member.name }} ({{ group.name }})</p> </template> </template>
3.9 Element refs: ref="name" + templateRef(name)
Mark any element with a plain ref attribute, then grab it in setup with templateRef:
<template component="search-box">
<input ref="input" on-input="onInput($event)" />
<button on-click="input.focus()">Focus</button>
<script setup>
import { templateRef, onInit } from '@li3/web';
export default function () {
const input = templateRef('input');
onInit(() => input.value.focus());
function onInput(e) { /* ... */ }
return { input, onInput };
};
</script>
</template>templateRef('input')returns a shallow ref holding the element (input.valueis theHTMLInputElement).- The ref is also merged into the template context under the same name, so templates can use it
directly (e.g.
on-click="input.focus()").
3.10 Emitting events from templates: $$emit
Every component context contains an injected $$emit(name, value) function that dispatches a
CustomEvent on the host element:
<button on-click="$$emit('save', { id: item.id })">Save</button>Equivalent to using defineEvent in setup (see §9), but callable
directly from template expressions.
3.11 Skipping subtrees: do-not-render
Any element carrying the do-not-render attribute is skipped by the binding walker — neither it nor
its children are processed. Useful for raw content that may contain {{ ... }} sequences:
<pre do-not-render>Literal mustache: {{ not-evaluated }}</pre>4. Template expressions: rules and semantics
All binding expressions ({{ }}, on-*, bind-*, attr-*, class-*, style-*, if, for)
follow the same rules:
- Plain JavaScript. Expressions are compiled with
new Function.ifis wrapped inBoolean(...);forsources inArray.from(... || []); text interpolations become template literals. - The context is the setup's return value merged with props (
defineProp) and template refs (templateRef/ref="..."attributes), plus the injected$$emitfunction. Only context keys whose names literally appear in the expression are in scope for that expression. - Refs auto-unwrap on read. A context value created with
ref/computedis transparently replaced by its.valuein templates:{{ count }},count > 2,items.lengthall read the unwrapped value. Refs also implementSymbol.toPrimitive, so arithmetic/coercion works even when a ref slips through (count + 2). - The context is read-only. Assigning to a top-level context variable in a template
(
on-click="count = count + 1") throws"View contexts are read-only". Call setup methods instead:on-click="increment()". Two exceptions:- Mutating inside an object works via deep reactivity:
on-input="user.name = $event.target.value"mutates the reactive object held by theuserref. - Host element props can be mutated through
$event.targetor element refs:on-click="$event.target.trigger++".
- Mutating inside an object works via deep reactivity:
- Reactivity is automatic. Each binding re-evaluates its expression when any signal read during evaluation changes. DOM updates are scheduled asynchronously (a ~5ms queue).
- Functions are values. Methods returned from setup (
increment,save, ...) are available by name; call them in event handlers:on-click="increment()".
5. Declaring components in HTML
5.1 The component template
<template component="my-element">
<!-- view markup with any template syntax -->
<p>{{ message }}</p>
<!-- optional behavior -->
<script setup>
import { ref } from '@li3/web';
export default function () {
const message = ref('hi');
return { message };
};
</script>
</template>- The element name must be a valid custom element name (contain a dash; SVG-reserved names like
font-faceare rejected). - In-document
template[component]blocks are registered automatically on page load. Each<my-element>in the page (or added later) mounts the template. - Optional
shadow-domattribute enables shadow DOM:shadow-dom="open",shadow-dom="closed", or a JSONShadowRootInitsuch asshadow-dom='{"mode":"open","delegatesFocus":true}'. - The
<template>tag itself is not rendered; its content is cloned into each element instance. - A template may contain, in any order: view markup, one
<script setup>, one<script state>, any number of<style>/<link rel="stylesheet">,<link rel="component">, and<ref>tags. All of these special children are removed from the template before mounting and never appear in the rendered output.
5.2 The app template
<template app> is a component without a name that mounts immediately at its own location (into a
<div style="display: contents"> inserted where the template was, which is then removed). Use it for
the application root — or several independent apps on one page.
<template app>
<h1>{{ title }}</h1>
<script setup>
import { ref } from '@li3/web';
export default function () {
return { title: ref('My App') };
};
</script>
</template>5.3 Setup scripts: <script setup>
The component's behavior module. Its default export is the setup function, executed once per
component instance. Everything the template needs must be returned as an object — forgetting
return yields an empty context.
<script setup>
import { ref, computed, defineProp, defineEvent, onInit, templateRef } from '@li3/web';
export default function () {
const initial = defineProp('initial', { default: 0 });
const count = ref(initial.value);
const doubled = computed(() => count.value * 2);
const onSave = defineEvent('save');
const input = templateRef('input');
const increment = () => count.value++;
const save = () => onSave(count.value);
onInit(() => console.log('mounted'));
return { count, doubled, increment, save, input };
};
</script>- Setup scripts are ES modules: top-level
importworks (e.g.from '@li3/web'via import map, or relative URLs resolved against the page / the file the template was loaded from). - Instead of inline code, an external file can be referenced:
<script setup src="./logic.js"></script>(resolved relative to the component file). - The script runs once per element instance when the element connects to the DOM.
5.4 Declarative JSON state: <script state>
Components with no behavior can skip the setup script and declare their initial context as JSON:
<template app>
<script state type="application/json">
{
"title": "Facts",
"facts": [
{ "title": "One", "content": "..." },
{ "title": "Two", "content": "..." }
]
}
</script>
<h1>{{ title }}</h1>
<template foreach="fact of facts">
<article>
<h3>{{ fact.title }}</h3>
<p>{{ fact.content }}</p>
</article>
</template>
</template>When both state and setup exist, each JSON entry is applied onto the setup result: writable
refs receive .value = entry, other keys are overwritten. This lets HTML authors inject content into
a scripted component:
<template app>
<script state type="application/json">{ "title": "Overridden" }</script>
<script setup>
import { ref } from '@li3/web';
export default function () {
return { title: ref('Default title') };
};
</script>
<h1>{{ title }}</h1>
</template>5.5 Declarative state refs: <ref>
Individual reactive values can be declared inside a template with <ref> tags (removed before mount):
<template app>
<ref name="count" value="0" setter="setCount"></ref>
<ref name="user" value='{"name":"Ada","admin":true}'></ref>
<p>{{ count }} — {{ user.name }} ({{ user.admin }})</p>
<button on-click="setCount(count + 1)">+1</button>
</template>name— the context variable (aref).value— parsed smartly:'true'/'false'→ boolean, numbers/JSON/objects/arrays via expression evaluation, anything else → string.setter(optional) — creates a setter function under that name:setCount(v)setscount.value = v. Without a setter, mutations must go through a setup function (or object mutation likeuser.name = x).
5.6 Styles: <style> and <link rel="stylesheet">
Style tags and stylesheet links inside a component template are converted to CSSStyleSheets and added
to adoptedStyleSheets of the shadow root (or the document, when no shadow DOM) at mount time:
<template component="ui-card" shadow-dom="open">
<div class="card"><slot></slot></div>
<style>
.card { padding: 1rem; border: 1px solid #ccc; border-radius: 0.5rem; }
</style>
<link rel="stylesheet" href="./card.css" />
</template>- Relative
hrefs resolve against the component file URL. - CSS is loaded as a CSS module (
import ... with { type: 'css' }) with an@import url(...)fallback. - Stylesheets are shared/cached per URL.
Additionally, loadCss(href, options?) can be called during setup to load and adopt a stylesheet
programmatically ({ adopt: false } skips adoption and just returns Promise<CSSStyleSheet>).
5.7 Component dependencies: <link rel="component">
A component file (or the page itself) can declare dependencies on other component files:
<!-- in the page head: loaded and registered at startup -->
<link rel="component" href="./components/ui-kit.html" />
<!-- inside a component template: loaded when the component is defined -->
<template component="user-dashboard">
<link rel="component" href="./widgets.html" />
<user-card></user-card>
</template>URLs inside a loaded file resolve relative to that file. Loads are cached per resolved URL.
5.8 Shadow DOM and slots
With shadow-dom, the template renders into the element's shadow root and native <slot> works:
<template component="ui-card" shadow-dom="open">
<div class="card">
<span class="card-title">{{ title }}</span>
<slot></slot>
<slot name="footer"></slot>
</div>
<style>.card { padding: 1rem; }</style>
</template>
<ui-card title="Hello">
<p>Default slot content</p>
<footer slot="footer">Named slot content</footer>
</ui-card>Without shadow-dom, content renders in light DOM and <slot> has no effect (there is no custom
slot implementation).
6. Full component examples
6.1 Counter (single file app)
<script type="importmap">{ "imports": { "@li3/": "https://cdn.li3.dev/@li3/" } }</script>
<script type="module">import '@li3/web';</script>
<template app>
<h1>{{ title }}</h1>
<div class-warning="count > 10">
<p>Count: <strong>{{ count }}</strong> (double: {{ doubled }})</p>
<template if="count > 0">
<p>You've clicked {{ count }} times</p>
</template>
</div>
<button on-click="increment()" bind-disabled.bool="count > 5">Increment</button>
<button on-click="reset()">Reset</button>
<script setup>
import { ref, computed } from '@li3/web';
export default function () {
const title = ref('Counter App');
const count = ref(0);
const doubled = computed(() => count.value * 2);
const increment = () => count.value++;
const reset = () => (count.value = 0);
return { title, count, doubled, increment, reset };
};
</script>
</template>6.2 Reusable component with props, events and shadow DOM
<template component="ui-card" shadow-dom="open">
<div class="card">
<span class="card-title">{{ title }}</span>
<slot></slot>
<button on-click="select()">Select</button>
</div>
<script setup>
import { defineProp, defineEvent } from '@li3/web';
export default function () {
const title = defineProp('title', { default: '' });
const onSelect = defineEvent('select');
const select = () => onSelect(title.value);
return { title, select };
};
</script>
<style>
.card { padding: 1rem; margin: 1rem auto; border-radius: 0.5rem; border: 1px solid #ccc; }
.card-title { color: #999; text-transform: uppercase; font-size: 0.75rem; }
</style>
</template>
<ui-card title="First card" on-select="onCardSelect($event)">
<p>Card content</p>
</ui-card>6.3 Lifecycle + props: count-trigger
<template component="count-trigger">
<div>
Ready? {{ ready }}<br />
Update count: {{ count }}
</div>
<script setup>
import { onInit, onUpdate, ref, defineProp } from '@li3/web';
export default function () {
const trigger = defineProp('trigger');
const ready = ref(false);
const count = ref(0);
onInit(() => (ready.value = true));
onUpdate(() => count.value++);
return { trigger, ready, count };
};
</script>
</template>
<count-trigger bind-trigger="0" on-click="$event.target.trigger++"></count-trigger>6.4 A todo list (loops, class bindings, two-way-ish input)
<template app>
<form on-submit.prevent="add()">
<input ref="draft" attr-placeholder="'What needs to be done?'" />
<button attr-disabled.bool="!draft.value.trim()">Add</button>
</form>
<p>{{ remaining }} of {{ todos.length }} remaining</p>
<ul>
<template foreach="[todo, i] of todos">
<li class-done="todo.done">
<span on-click="toggle(todo)">{{ i + 1 }}. {{ todo.title }}</span>
<button on-click="remove(todo)">x</button>
</li>
</template>
</ul>
<template if="!todos.length">
<p>Nothing to do. 🎉</p>
</template>
<script setup>
import { ref, computed, templateRef } from '@li3/web';
export default function () {
const draft = templateRef('draft');
const todos = ref([
{ title: 'Learn Lithium', done: true },
{ title: 'Build an app', done: false },
]);
const remaining = computed(() => todos.value.filter((t) => !t.done).length);
function add() {
todos.value.push({ title: draft.value.value.trim(), done: false });
draft.value.value = '';
}
const toggle = (todo) => (todo.done = !todo.done);
const remove = (todo) => (todos.value = todos.value.filter((t) => t !== todo));
return { draft, todos, remaining, add, toggle, remove };
};
</script>
</template>Note the patterns: array mutations (todos.value.push(...)) and nested object mutations
(todo.done = ...) are reactive; replacing the array (todos.value = filtered) also works.
6.5 Script-less app with <ref> and <script state>
<template app>
<ref name="count" value="0" setter="setCount"></ref>
<script state type="application/json">
{ "concepts": ["interpolation", "events", "loops"] }
</script>
<button on-click="setCount(count + 1)">Count: {{ count }}</button>
<ul>
<template foreach="c of concepts">
<li>{{ c }}</li>
</template>
</ul>
</template>7. Reactivity in depth
All reactive primitives are signals: objects with a .value property (type Signal<T> = { value: T }).
ref(value, isShallow?)
Creates a writable signal.
import { ref } from '@li3/web';
const count = ref(0);
count.value++; // triggers updates
console.log(count.value); // 1- Deep by default: object/array values are wrapped in a recursive reactive proxy, so nested
mutations trigger updates:
user.value.name = 'Ada',list.value.push(x). - Setting
.valueskips notification when the new value compares equal to the old one (deep comparison for arrays/dates/regexps; seecompare). - Refs coerce via
Symbol.toPrimitive:count + 2and`n=${count}`work without.value.
shallowRef(value)
A ref without deep wrapping — nested mutations do not trigger updates; only replacing .value does.
templateRef() returns a shallow ref (so input.value.value = 'x' on an element ref isn't tracked,
which is what you want).
computed(fn)
A read-only derived signal. Re-evaluates lazily when any signal read inside fn notifies.
import { ref, computed } from '@li3/web';
const a = ref(1);
const b = ref(2);
const sum = computed(() => a.value + b.value);
sum.value; // 3
a.value = 5;
sum.value; // 7
sum.value = 9; // throws: "Computed value cannot be set"Errors thrown inside fn are swallowed and yield null (logged when debug is on).
watch(signal, callback, options?)
Calls callback(newValue, lastValue) whenever the signal changes. Returns an unsubscribe function.
import { ref, watch } from '@li3/web';
const count = ref(0);
const unwatch = watch(count, (value, lastValue) => {
console.log(`${lastValue} -> ${value}`);
});
count.value = 1; // logs "0 -> 1"
unwatch(); // stop watching- The first invocation is scheduled asynchronously (~5ms queue) unless
{ immediate: true }is passed, in which case it fires synchronously with the current value.
effect(fn, effectFn, options?)
effect(fn, effectFn) is watch(computed(fn), effectFn): fn computes a tracked value; whenever it
changes, effectFn(newValue, lastValue) runs. All DOM bindings are built on this.
import { ref, effect } from '@li3/web';
const user = ref({ name: 'Ada' });
effect(
() => user.value.name.toUpperCase(),
(name) => console.log('name changed:', name),
);
user.value.name = 'Grace'; // logs "name changed: GRACE"hook(initialValue, isShallow?)
React-style tuple:
import { hook } from '@li3/web';
const [count, setCount] = hook(0);
setCount(42);
console.log(count.value); // 42reactive(object, effectFn)
Creates a deep reactive proxy of object that calls effectFn on any change (set or delete, at any
depth). Non-objects and already-reactive objects pass through unchanged.
import { reactive } from '@li3/web';
const state = reactive({ user: { name: 'Ada' }, items: [] }, () => console.log('changed'));
state.user.name = 'Grace'; // logs "changed"
state.items.push(1); // logs "changed"unwrap(object)
Returns the raw value: the inner object for reactive proxies, .value for refs/computed, otherwise
the input unchanged. null/undefined pass through.
isRef(x), isReadOnlyRef(x), isWritableRef(x)
Type guards. computed results are read-only; ref results are writable.
suspend(signal) / resume(signal)
Suspend a signal: writes to .value become no-ops and its dependencies are dropped. resume clears
the flag and forces a re-update. Used internally to freeze rows removed by <template for>.
canBeObserved(object)
True for plain objects that are not already reactive proxies. Internal use.
Timing
- Signal notification is synchronous to dependents (computed re-evaluation).
watch/effectfirst callbacks are queued (~5mssetTimeout, trailing); subsequent notifications run synchronously. All DOM updates from bindings flow through this, so DOM reflects state changes asynchronously — don't assert on the DOM synchronously after a state change.
Use await nextTick() when code needs to wait for the scheduled reactive work:
import { nextTick } from '@li3/web';
count.value++;
await nextTick();
// DOM bindings have now processed the queued update.Resources created during setup and template linking are disposed when the component is unmounted. Application code can register its own cleanup:
import { onCleanup } from '@li3/web';
export default function () {
const timer = setInterval(refresh, 1000);
onCleanup(() => clearInterval(timer));
return { refresh };
}8. Props
defineProp(name, options?) declares an input. Call it in setup; it returns a writable ref and also
exposes the prop in the template context by name.
import { defineProp } from '@li3/web';
export default function () {
const title = defineProp('title', { default: 'Untitled' });
const items = defineProp('items', { default: () => [] });
const active = defineProp('active', { default: false, attribute: true });
return { title, items, active };
}Options (PropOptions):
default?: T | (() => T)— used when neither the element property nor attribute is set. A function is invoked (use it for object/array defaults).attribute?: boolean— when true, setting the prop reflects it back to the HTML attribute (setAttribute(name, String(value))).
Initial value resolution, in order:
- The element property if already set (
el.title = 'Hi'before connection). - The element attribute, parsed smartly:
'true'/'false'→ boolean, JSON/JS expressions and numbers evaluated (value='[1,2,3]'→ array), anything else → string. - The
default.
Setting props from a parent — use bind-* (property assignment) so any value type works:
<user-list bind-users="allUsers" bind-page-size="25"></user-list>- Setting
el.prop = value(whichbind-*does) updates the ref, firesonUpdatehooks (debounced 1ms), and reflects to the attribute whenattribute: true. - Plain attributes (
<user-list page-size="25">) only initialize the prop; attribute changes after mount are not observed. - Prop refs are writable inside the component (
title.value = 'x'), which also updatesel.title.
9. Events between components
defineEvent(name) declares an output and returns an emitter function. Call it with a payload:
import { defineEvent, ref } from '@li3/web';
export default function () {
const onSave = defineEvent('save');
const draft = ref('');
const save = () => onSave(draft.value); // emits CustomEvent('save', { detail: draft.value })
return { draft, save };
}Parent listens with the standard event binding; the payload is in $event.detail:
<editor-pane on-save="handleSave($event)"></editor-pane>function handleSave(e) {
console.log('saved:', e.detail);
}- Emitted events are
CustomEvents on the host element. They do not bubble (bubbles/composedare not set), so listeners must be on the element itself — exactly whaton-<name>does. - Setting a function property
element.on<name> = fnalso receives the event (native-style handler). - From template expressions, use the injected
$$emit(name, value)directly:on-click="$$emit('close')". - Convention: event names are lowercase, no dashes.
10. Lifecycle hooks
All lifecycle functions must be called during setup (they need the current component context and
throw "Missing context for this component" otherwise).
import { onInit, onUpdate, onDestroy, getElement } from '@li3/web';
export default function () {
onInit(() => {
// DOM is in place; good for focus(), measurements, starting timers
getElement().querySelector('input')?.focus();
});
onUpdate(() => {
// one or more props changed (debounced ~1ms)
});
onDestroy(() => {
// element disconnected / unmounted; clear timers, abort requests
});
return {};
}getElement()returns the component's host element (also only during setup).
11. Loading components at runtime: load
load(href, baseUrl?) fetches an HTML file, registers every <template component="..."> in it, and
resolves to the list of definitions. Relative URLs inside the file (setup src, stylesheets, nested
<link rel="component">) resolve against the file URL. Results are cached per resolved URL; failures
log to the console and resolve to [].
import { load } from '@li3/web';
await load('./components/ui-kit.html');
// <ui-card> etc. are now defined and upgrade automaticallyComponent file format — a complete example:
<!-- ui-kit.html -->
<template component="ui-badge">
<ref name="kind" value="'info'"></ref>
<span class-badge="true" class-info="kind === 'info'" class-warn="kind === 'warn'">
<slot></slot>
</span>
<style>
.badge { padding: 0.25rem 0.5rem; border-radius: 1rem; }
.info { background: #def; }
.warn { background: #fed; }
</style>
</template>
<template component="ui-card" shadow-dom="open">
<link rel="component" href="./ui-avatar.html" />
<link rel="stylesheet" href="./ui-card.css" />
<div class="card">
<ui-avatar bind-user="author"></ui-avatar>
<h3>{{ title }}</h3>
<slot></slot>
</div>
<script setup>
import { defineProp, defineEvent } from '@li3/web';
export default function () {
defineProp('title', { default: '' });
defineProp('author', { default: null });
const onOpen = defineEvent('open');
return { onOpen };
};
</script>
</template>12. Programmatic mounting: mount
mount(targetElement, options) mounts a component onto an existing element and returns an unmount
function. Options (MountOptions):
{
template: HTMLTemplateElement | string; // a <template> or an HTML string
setup?: () => object; // the setup function
styles?: CSSStyleSheet[]; // adopted into shadowRoot or document
shadowDom?: boolean | ShadowRootInit; // true = { mode: 'open' }
refs?: [name, value, setterName][]; // declarative refs
}import { mount, ref } from '@li3/web';
const unmount = mount(document.getElementById('app'), {
template: `<p>{{ message }}</p><button on-click="increment()">{{ count }}</button>`,
setup() {
const message = ref('Hello');
const count = ref(0);
const increment = () => count.value++;
return { message, count, increment };
},
});
// later: unmount();defineComponent(name, options) registers a custom element class with the same options. Note it is
part of the component module but not re-exported from the package entry point — prefer
<template component> / load() for HTML-driven apps, and mount() for programmatic ones.
autoInitialize() runs the startup pass manually (define in-document template[component], load
link[rel=component], mount template[app]). It runs automatically unless the skipAutoInitialize
flag is set.
13. The extension system: use
All template syntax is implemented as rules, and you can add your own:
use({
match(node: Element, attribute: string, value: string): boolean,
exec(node: Element, attribute: string, value: string, context: any): void,
});- For every attribute of every element, rules are tried in registration order; the first match
runs
exec, and the attribute is removed from the DOM. - Built-in rules (also exported, so you can subclass them):
TemplateIf,TemplateForeach,AddEventListener,SetProperty,SetAttribute,SetClassName,SetStyle. contextis the read-only template context. Expressions are compiled with the internalcreateFunction(expression, context, args); wire reactivity witheffect/watch.
Example — a custom tooltip directive:
import { use, effect } from '@li3/web';
use({
match: (node, name) => name === 'tooltip',
exec: (node, name, value, context) => {
// naive implementation: evaluate `value` against context and keep title in sync
const fn = new Function(`with(this) { return ${value}; }`).bind(context);
effect(fn, (v) => node.setAttribute('title', v ?? ''));
},
});<span tooltip="user.fullName">hover me</span>Example — Vue-style syntax (this is how the sibling @li3/use package implements useVue()):
subclass the built-ins and remap attribute names:
import { use, TemplateForeach, TemplateIf, SetProperty, AddEventListener } from '@li3/web';
class VueSetProperty extends SetProperty {
match(node, name) { return name.startsWith(':'); }
exec(node, name, value, context) {
return super.exec(node, 'bind-' + name.slice(1), value, context);
}
}
class VueEventListener extends AddEventListener {
match(node, name) { return name.startsWith('@'); }
exec(node, name, value, context) {
return super.exec(node, 'on-' + name.slice(1), value, context);
}
}
class VueTemplateIf extends TemplateIf {
match(node, name) { return name === 'v-if'; }
exec(node, name, value, context) {
const t = document.createElement('template');
node.replaceWith(t);
node.removeAttribute('v-if');
t.content.append(node);
super.exec(t, 'if', value, context);
}
}
class VueTemplateFor extends TemplateForeach {
match(node, name) { return name === 'v-for'; }
exec(node, name, value, context) {
const t = document.createElement('template');
node.replaceWith(t);
node.removeAttribute('v-for');
t.content.append(node);
super.exec(t, 'foreach', value, context);
}
}
use(new VueSetProperty());
use(new VueEventListener());
use(new VueTemplateIf());
use(new VueTemplateFor());This enables :value="x", @click="fn()", v-if="cond", v-for="item of items" on any element.
14. Feature flags and debugging
Set flags via setFeatureFlag(name, value?) (must run before components initialize) or by listing them
comma-separated in window.name (e.g. window.name = 'debug' — note this replaces the window name).
| Flag | Effect |
|---|---|
| debug | Keeps processed binding attributes on elements; keeps <template app> elements in the DOM; attaches every signal to window.refList (a Set of WeakRefs); logs component redefinition and computed errors. |
| strictCompare | Uses === instead of the deep-ish compare for change detection. |
| skipAutoInitialize | Disables the automatic startup pass; call autoInitialize() yourself. |
Debugging aids:
- Every mounted root element carries its merged context under a symbol key; with
debugon, processed attributes stay visible in devtools, andwindow.refListlets you inspect live signals. - Set
window.nametodebugin any page to attach component context to components. <template if>/<template for>leave comment anchors (<!--if: ...-->,<!--for: ...-->) in the DOM, which helps trace conditional/list regions in devtools.
15. Complete syntax cheat sheet
| Feature | Syntax | Example |
|---|---|---|
| Interpolation | {{ expr }} in text nodes | <p>{{ user.name }}</p> |
| Event | on-<event>="expr" | <button on-click="save()"> |
| Event modifiers | .stop .prevent .self | <form on-submit.prevent="save()"> |
| Event payload | $event ($event.detail for custom events) | on-input="onInput($event)" |
| Property binding | bind-<prop>="expr" (camelCased) | <input bind-value="name"> |
| Boolean property | bind-<prop>.bool="expr" | <button bind-disabled.bool="busy"> |
| Class map | bind-class="{ cls: cond }" | <div bind-class="{ active: isOn }"> |
| Style map | bind-style="{ prop: value }" | <div bind-style="{ color: c }"> |
| Attribute binding | attr-<name>="expr" | <div attr-data-id="id"> |
| Boolean attribute | attr-<name>.bool="expr" | <input attr-readonly.bool="locked"> |
| Class toggle | class-<name>[.<name2>]="expr" | <li class-done="t.done"> |
| Style property | style-<prop>="expr" (camelCased) | <div style-color="c"> |
| Conditional | <template if="expr"> | <template if="user">...</template> |
| List | <template foreach="item of items"> | <template foreach="u of users">...</template> |
| List + index | <template foreach="[item, i] of items"> | <template foreach="[u, i] of users">...</template> |
| Element ref | ref="name" + templateRef('name') | <input ref="box"> |
| Declarative state | <ref name value setter> inside template | <ref name="n" value="0" setter="setN"> |
| JSON state | <script state type="application/json"> | { "title": "Hi" } |
| Setup module | <script setup> (or src=) | export default function () { ... } |
| Component | <template component="my-el" shadow-dom="open"> | custom element definition |
| App root | <template app> | auto-mounted |
| Styles | <style> / <link rel="stylesheet"> inside template | adopted stylesheets |
| Dependencies | <link rel="component" href="..."> | page or template level |
| Emit from template | $$emit('name', value) | on-click="$$emit('close')" |
| Skip subtree | do-not-render attribute | <pre do-not-render> |
| Custom rule | use({ match, exec }) | see §13 |
| Feature flags | setFeatureFlag('debug') / window.name | see §14 |
16. Gotchas and rules of thumb
- Return your bindings. The setup function's returned object is the template context. No
return→ empty context → bindings silently do nothing. - Templates are read-only.
on-click="count++"throws. Expose methods (increment()) and call them. Object internals may be mutated (user.name = x). forusesof, notin. There is no keying; rows are reused by index and their item values updated in place.- No
else. Use complementary<template if>conditions or computed flags. - DOM updates are async. Bindings flush on a ~5ms queue;
if/forinsertions happen on a timeout. Read the DOM after asetTimeout/await new Promise(r => setTimeout(r))if needed. - Custom events don't bubble. Listen with
on-<name>directly on the child element tag. - Attributes initialize props; properties drive them. After mount, set props via properties
(
bind-*/el.prop = v); attribute changes are not observed. - One rule per attribute, first match wins. Rule prefixes:
on-,bind-,attr-,class-,style-;if/foronly on<template>. - Binding attributes are stripped after processing (visible with the
debugflag). - Component names need a dash and must not be SVG-reserved names (
font-face, etc.). load()caches per URL and resolves to[]on failure (check the console).- Refs auto-unwrap in templates, but in setup code always use
.value(except in coercions, whereSymbol.toPrimitiveapplies). <slot>requires shadow DOM (shadow-dom="open"); there is no light-DOM slot polyfill.- Boolean attributes need
.bool(attr-disabled.bool="x"orbind-hidden.bool="x"); plainattr-disabled="false"sets the string"false", which is still truthy as an attribute presence. - Setup-side helpers require setup context:
defineProp,defineEvent,templateRef,onInit,onUpdate,onDestroy,getElement,loadCss(with adoption) throw outside a running setup function.
