Core concepts
The compile pipeline
Every template goes through the same four stages, whether at runtime or at build time:
The IR is a serializable description of the template: static HTML skeletons, a table of expressions, and integer paths pointing at the nodes each binding owns. Rendering clones a skeleton and walks those paths — it never parses HTML again, and never re-reads your template.
Because the IR is plain JSON it can be precompiled, stored, or shipped over the network.
Component architecture
A component is a plain object. Nothing is a class, and nothing needs a build step.
const userProfile = {
// reactive data owned by this component
state: { editing: false },
// non-reactive constants
_static: { roles: [ 'admin', 'editor' ] },
// context fields this component wants to observe
context: [ 'theme' ],
// methods + lifecycle hooks, bound to the component
handler: {
toggleEdit(){ this.state.editing = !this.state.editing },
save(){
this.state.editing = false
this.emit('save', this.input.user)
}
},
// the template
default: `
<div class="profile">
<h2>{input.user.name}</h2>
<if( state.editing )>
<form on-submit(save)>
<button type="submit">Save</button>
</form>
</if>
<else>
<p>{input.user.email}</p>
<button on-click(toggleEdit)>Edit</button>
</else>
</div>`,
// scoped CSS
stylesheet: `.profile { padding: 1rem }`
}
Every key is optional except default (or a precompiled ir).
| Key | Purpose |
|---|---|
default |
template source |
ir |
precompiled IR — replaces default |
state |
reactive, component-owned data |
_static |
constants; reading them never subscribes |
context |
context field names that trigger onContext |
handler |
methods and lifecycle hooks |
macros |
<macro> definitions inlined at compile time |
stylesheet |
scoped CSS |
The template key is _static, but inside a handler you read it as this.static. The
underscore only avoids colliding with the JS static keyword in module exports.
Reactivity
State is a per-key signal store. Reading a key inside a binding subscribes that binding to that key. Writing the key notifies exactly those subscribers — nothing else runs.
const counter = {
state: { count: 0, label: 'Clicks' },
handler: {
increment(){ this.state.count++ }
},
default: `
<div>
<h3>{state.label}</h3>
<p>Count: {state.count}</p>
<button on-click(increment)>+</button>
</div>`
}
Clicking runs one text update. The <h3> bound to state.label is never visited: it is
not compared, not re-created, not diffed. There is no component re-run to memoize away.
Deep reactivity
Nested objects and arrays are tracked too, so in-place mutation works:
this.state.user.address.city = 'Lomé'
this.state.items[0].name = 'Updated'
this.state.todos.push({ id: 3, text: 'New' })
Array mutators (push, splice, sort, …) are applied atomically — one operation produces
one update, not one per internal write.
Reactive Map and Set
Map and Set are first-class reactive state. Mutating them notifies; values read out of
them are wrapped in turn, so a nested tree of Maps stays reactive at any depth.
const tree = {
state: {
layers: new Map([
[ 'a', { name: 'Group A', children: new Map() } ]
])
},
handler: {
add(){ this.state.layers.set('b', { name: 'Layer B' }) },
remove( key ){ this.state.layers.delete( key ) }
},
default: `
<ul>
<for [key, layer] in=state.layers>
<li>{layer.name}</li>
</for>
</ul>`
}
Collection identity is stable across components: if a parent passes state.layers down as an
input, a .set() in the parent reaches the child rendering the same Map.
Static values
Anything that never changes belongs in _static, not state. Reading it creates no
subscription:
{
_static: { pageSize: 25, api: 'https://api.example.com' },
handler: {
async load(){
const res = await fetch(`${this.static.api}/items`)
return res.json()
}
}
}
Inputs
A parent passes data down as attributes; the child reads them from input.
<user-card name="Ada" age=36 admin=state.isAdmin/>
{
default: `
<div>
<h3>{input.name}</h3>
<if( input.admin )><span class="badge">admin</span></if>
</div>`,
handler: {
onInput( input ){
// called once at creation, with the input object
}
}
}
Inputs are reactive: when the parent’s expression re-evaluates, the child’s bindings that read that key update. The child never re-runs.
Lifecycle
{
handler: {
onCreate(){}, // instance created, before first render
onInput( input ){}, // initial input received
onMount(){}, // first render complete, nodes exist
onRender(){}, // after every render, including the first
onUpdate(){}, // after a state/input driven update
onAttach(){}, // nodes are live in the document
onDetach(){}, // nodes were removed from the document
onContext(){}, // an observed context field changed
onError( error ){}, // a binding or handler threw
onDestroy(){} // instance torn down
}
}
The order at creation is:
onAttach and onDetach fire by ownership, not by observing the whole document: a
component’s own render decides when its children are considered attached.
onError acts as a boundary — if a binding or a handler throws and the component defines
onError, it receives the error instead of the exception reaching the console.
Events
DOM events
<button on-click(handleClick)>Click</button>
<button on-click(remove, item.id)>Delete</button>
<button on-click( () => state.count++ )>Inline</button>
Declared arguments come first; the DOM event object is appended last:
handler: {
handleClick( event ){ /* … */ },
remove( id, event ){ event.preventDefault() }
}
Component events
A child emits; the parent listens with on-* on the tag.
// child
handler: {
submit(){ this.emit('submit', { name: this.state.name }) }
}
<!-- parent -->
<signup-form on-submit(handleSubmit)/>
// parent
handler: {
handleSubmit( data ){ console.log( data ) }
}
Every component also carries a small event bus of its own — on, once, off, emit — and
emits its own lifecycle events on it:
handler: {
onCreate(){
this.on('component:attached', () => this.bindDragHandles() )
this.on('component:detached', () => this.releaseDragHandles() )
}
}
Emitted lifecycle events: component:mount, component:attached, component:detached,
component:destroy.
Reaching the DOM
this.node returns the component’s live root elements as an array. It is the handle you give
to a third-party library.
handler: {
onMount(){
const [ root ] = this.node
root.querySelector('.chart') && renderChart( root )
}
}
this.node is an array of DOM Elements — plain DOM, no jQuery-like wrapper. Use standard
querySelector, classList, and addEventListener.
Next
- Template syntax — the full syntax reference
- Component API — every property and method on a component