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