Component API
Defining a component
A component is a plain object. There is no class to extend and no compiler to satisfy.
const counter = {
state: { count: 0 },
handler: {
increment(){ this.state.count++ },
decrement(){ this.state.count > 0 && this.state.count-- }
},
default: `
<div class="counter">
<h2>{state.count}</h2>
<button on-click(increment)>+</button>
<button on-click(decrement)>-</button>
</div>`,
stylesheet: `
.counter { padding: 1rem; text-align: center }
button { margin: 0 .5rem; padding: .5rem 1rem }`
}
As an ES module
Named exports work too — useful for one component per file with TypeScript types:
// counter.js
export const state = { count: 0 }
export const handler = {
increment(){ this.state.count++ }
}
export const stylesheet = `.counter { padding: 1rem }`
export default `
<div class="counter">
<h2>{state.count}</h2>
<button on-click(increment)>+</button>
</div>`
import * as counter from './counter.js'
lips.register('counter', counter)
Template keys
| Key | Type | Purpose |
|---|---|---|
default |
string |
template source |
ir |
TemplateIR |
precompiled IR — used instead of default |
state |
object |
reactive, component-owned data |
_static |
object |
constants; read as this.static |
context |
string[] |
context fields that trigger onContext |
handler |
object |
methods and lifecycle hooks |
macros |
string |
<macro> definitions, inlined at compile time |
stylesheet |
string |
scoped CSS |
Registering
const lips = new Lips()
lips.register('counter', counter)
lips.has('counter') // → true
lips.unregister('counter')
Names are resolved lazily at render time, so registration order does not matter.
Rendering
// render a component and place it
const c = lips.render('counter', counter)
c.appendTo('#app')
// with initial input
const profile = lips.render('profile', userProfile, { userId: 42 })
// mount an application root
lips.root(appTemplate, '#app')
The component handle
render() returns a handle with a deliberately small surface:
| Member | Description |
|---|---|
state |
the component’s reactive state store |
node |
live root elements, as an array of DOM Element |
appendTo( target ) |
append into a selector or element |
swap( ir ) |
replace the template, keeping state — see hot-swap |
destroy() |
tear down: effects, listeners, styles, DOM |
on / once / off / emit |
event bus |
const c = lips.render('counter', counter).appendTo('#app')
c.state.count = 10 // drives the DOM
c.on('changed', v => {}) // listen to what the component emits
c.destroy()
Inputs
Inputs are the attributes a parent puts on the tag.
<user-card name="Ada" age=36 admin=state.isAdmin/>
const userCard = {
default: `
<div class="card">
<h3>{input.name}</h3>
<p>{input.age}</p>
<if( input.admin )><span class="badge">admin</span></if>
</div>`,
handler: {
onInput( input ){
// called once at creation, receiving the input object
}
}
}
Inputs are reactive. When the parent’s expression re-evaluates, the bindings in the child that read that key update — the child itself never re-runs.
Spread inputs work as well, and keys that disappear from the object are removed:
<user-card ...state.user admin/>
State
State is reactive by assignment.
{
state: { count: 0, user: { name: 'Ada' }, tags: new Set() },
handler: {
bump(){
this.state.count++ // primitive
this.state.user.name = 'Grace' // nested object
this.state.tags.add('new') // Set
}
}
}
Arrays, objects, Map and Set are deeply reactive — mutating in place notifies. Array
mutators are atomic: one push is one update, not one per internal write.
Static
_static in the template, this.static in a handler. Reading it creates no subscription, so
it never causes a re-render.
{
_static: {
api: { baseUrl: 'https://api.example.com', timeout: 5000 },
isEmail: v => /^.+@.+\..+$/.test( v )
},
handler: {
async load(){
const res = await fetch(`${this.static.api.baseUrl}/data`)
return res.json()
}
}
}
Context
Context is shared state, declared once on the Lips instance and observed per component.
const lips = new Lips({
context: { theme: 'light', user: { id: 1, name: 'Guest' } }
})
const themed = {
// declare which fields this component cares about
context: [ 'theme', 'user' ],
default: `
<div class="app theme-{context.theme}">
<p>Welcome, {context.user.name}</p>
</div>`,
handler: {
onContext(){
// fires only when `theme` or `user` changes
}
}
}
Updating context, from outside or inside a component:
lips.setContext('theme', 'dark')
lips.setContext({ user: { id: 2, name: 'Ada' } })
handler: {
toggleTheme(){
this.setContext('theme', this.context.theme === 'dark' ? 'light' : 'dark')
}
}
Bindings that read context.theme update regardless of whether the component declared it.
The context: [ … ] list only controls when onContext fires.
Lifecycle
{
handler: {
onCreate(){}, // instance created, before first render
onInput( input ){}, // initial input received
onMount(){}, // first render done, 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(){}, // a declared context field changed
onError( error ){}, // a binding or handler threw
onDestroy(){} // instance torn down
}
}
Order at creation:
Order at teardown:
onAttach and onDetach fire by ownership — a parent’s render decides when its children
count as attached. There is no document-wide observer.
Error boundaries
{
handler: {
onError( error ){
this.state.failed = true
report( error )
}
},
default: `
<if( state.failed )>
<p class="error">Something went wrong.</p>
</if>
<else>
<risky-widget data=state.data/>
</else>`
}
Without onError, the error is logged to the console instead.
The component event bus
Every component has on, once, off and emit.
// child emits
handler: {
submit(){ this.emit('submit', { name: this.state.name }) }
}
<!-- parent listens -->
<signup-form on-submit(handleSubmit)/>
The same bus carries lifecycle events, which is how external controls attach to a component without a lifecycle hook:
handler: {
onCreate(){
this.on('component:attached', () => this.enableDrag() )
this.on('component:detached', () => this.disableDrag() )
}
}
| Event | Fires when |
|---|---|
component:mount |
the first render completed |
component:attached |
nodes became live in the document |
component:detached |
nodes were removed |
component:destroy |
the instance was torn down |
DOM access
this.node is an array of the component’s live root elements — plain DOM.
handler: {
onMount(){
const [ root ] = this.node
this.chart = new Chart( root.querySelector('canvas') )
},
onDestroy(){
this.chart?.destroy()
}
}
Handler names cannot shadow the component API. state, input, static, context, emit,
on, once, off, node, destroy, appendTo, prependTo, replaceWith, render and
swap are reserved — using one as a handler name throws at registration.