{ } Lips v0.2.0

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:

onCreate ──▶ onInput ──▶ render ──▶ onMount ──▶ onRender ──▶ onAttach

Order at teardown:

onDetach ──▶ onDestroy

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.