{ } Lips v0.2.0

Best practices

Component design

Keep components focused

Split by responsibility, not by size. A component that renders a header, a sidebar and a content area is three components.

// one component doing everything
const dashboard = {
  default: `
    <div>
      <header><!-- 40 lines --></header>
      <aside><!-- 60 lines --></aside>
      <main><!-- 80 lines --></main>
    </div>`
}

// composed instead
const dashboard = {
  default: `
    <div>
      <app-header/>
      <app-sidebar/>
      <main-content/>
    </div>`
}

Data down, events up

// parent owns the data
const parent = {
  state: { user: { id: 1, name: 'Ada' } },

  handler: {
    onUserSaved( updated ){ this.state.user = updated }
  },

  default: `<user-form user=state.user on-save(onUserSaved)/>`
}

// child reports, never reaches upward
const userForm = {
  handler: {
    save(){ this.emit('save', { ...this.input.user, name: this.state.draft }) }
  }
}

Use context only for genuinely cross-cutting values — theme, session, locale. Everything else is cheaper to follow as an input.

Keep logic out of templates

Templates read better when they describe structure, not computation.

// hard to read, and the expression re-runs on every dependency change
default: `
  <for [item] in=state.items.filter( i =>
    i.category === state.category && i.price > state.min && !i.hidden )>
    <li>{item.name}</li>
  </for>`

// the same work, named
handler: {
  visibleItems(){
    return this.state.items.filter( i =>
      i.category === this.state.category && i.price > this.state.min && !i.hidden )
  }
},
default: `
  <for [item] in=self.visibleItems()>
    <li>{item.name}</li>
  </for>`

Performance

Lips is fine-grained by default: a binding costs what it reads, and nothing re-runs a component. Most “optimization” in other frameworks has no equivalent here. The few things that do matter:

Key your lists

<!-- positional: a reorder rewrites every row -->
<for [todo] in=state.todos>
  <todo-row todo=todo/>
</for>

<!-- keyed: rows move, and their component state survives -->
<for [todo] in=state.todos by="id">
  <todo-row todo=todo/>
</for>

This is the single highest-impact habit in a Lips app. Without by, a nested component’s internal state follows the position, not the row.

Put constants in _static

Reading _static creates no subscription:

// reactive machinery for values that never change
state: { items: [], pageSize: 25, categories: [ 'a', 'b' ] }

// better
state: { items: [] },
_static: { pageSize: 25, categories: [ 'a', 'b' ] }

Read narrowly

A binding subscribes to exactly the keys it reads. Reading a whole object where you need one field widens that subscription:

<!-- re-runs when any field of user changes -->
<p>{JSON.stringify( state.user )}</p>

<!-- re-runs only when the name changes -->
<p>{state.user.name}</p>

Prefer macros for repeated markup

A macro is inlined at compile time — no instance, no lifecycle, no input wiring. If the thing has no state of its own, it should probably be a macro.

Error handling

Give risky subtrees a boundary

{
  handler: {
    onError( error ){
      this.state.failed = true
      report( error )
    }
  },

  default: `
    <if( state.failed )>
      <p class="error">Could not load this section.</p>
    </if>
    <else>
      <risky-widget data=state.data/>
    </else>`
}

Without onError, the error goes to the console and the binding is skipped.

Handle async in the template

<async await( self.loadUser( state.id ) )>
  <loading><spinner/></loading>
  <then [user]><user-card user=user/></then>
  <catch [error]><p class="error">{error.message}</p></catch>
</async>

This is usually clearer than mirroring loading / error / data into state by hand.

Clean up what you created

handler: {
  onMount(){
    this.timer = setInterval( () => this.state.now = Date.now(), 1000 )
    this.chart = new Chart( this.node[0] )
  },
  onDestroy(){
    clearInterval( this.timer )
    this.chart.destroy()
  }
}

Bindings, effects and listeners created by the template are disposed for you. Anything you allocate in a hook is yours to release.

Project structure

src/
├── components/
│   ├── common/      button.js, card.js, input.js
│   ├── layout/      header.js, sidebar.js, footer.js
│   └── features/
│       ├── user/    profile.js, settings.js
│       └── products/ list.js, detail.js
├── services/        api.js, auth.js
└── app.js

Register in one place so dependencies are visible:

// app.js
import Lips from '@lipsjs/lips'
import * as components from './components/index.js'

const lips = new Lips({ context: { theme: 'light' } })

Object.entries( components ).forEach( ( [ name, c ] ) => lips.register( name, c ) )

TypeScript

Type a component with Metavars and Template:

import type { Metavars, Template } from '@lipsjs/lips'

interface Todo { id: number, text: string, done: boolean }

type TodoList = Metavars<
  { title?: string },              // Input
  { todos: Todo[], filter: string }, // State
  { pageSize: number },            // Static
  { theme: string }                // Context
>

const todoList: Template<TodoList> = {
  state: { todos: [], filter: 'all' },
  _static: { pageSize: 25 },

  handler: {
    add( text: string ){
      if( !text.trim() ) return
      this.state.todos.push({ id: Date.now(), text, done: false })
    },
    visible(): Todo[] {
      switch( this.state.filter ){
        case 'active': return this.state.todos.filter( t => !t.done )
        case 'done':   return this.state.todos.filter( t => t.done )
        default:       return this.state.todos
      }
    }
  },

  default: `
    <div class="todo-list">
      <h2>{input.title || 'Todo'}</h2>
      <for [todo] in=self.visible() by="id">
        <li class=(todo.done && 'done')>{todo.text}</li>
      </for>
    </div>`
}

Shipping

For production, precompile: templates compile at build time, the parser never ships, and template errors fail the build instead of surfacing in the browser.

// vite.config.js
import { lipsPlugin } from '@lipsjs/lips/precompile'
export default { plugins: [ lipsPlugin() ] }
import Lips from '@lipsjs/lips/runtime'   // ~13 KB gzip