Context & macros
Context
Context is shared state on the Lips instance. Any component can read it; components that declare an interest get a hook when it changes.
const lips = new Lips({
context: {
theme: 'light',
user: { id: 1, name: 'Guest' },
permissions: [ 'read' ]
}
})
Reading
Reading context.x in a template subscribes that binding to x — no declaration needed:
<div class="app theme-{context.theme}">
<p>Welcome, {context.user.name}</p>
</div>
Reacting
Declaring context: [ … ] controls only when onContext fires:
const panel = {
context: [ 'theme', 'user' ],
handler: {
onContext(){
// runs when `theme` or `user` changes — not for other fields
this.applyTheme( this.context.theme )
}
}
}
Writing
// from outside
lips.setContext('theme', 'dark')
lips.setContext({ user: { id: 2, name: 'Ada' }, permissions: [ 'read', 'write' ] })
// from inside a component
handler: {
toggle(){ this.setContext('theme', this.context.theme === 'dark' ? 'light' : 'dark') }
}
Subscribing from code
// fires when any listed field changes
const stop = lips.watchContext([ 'theme' ], () => {
document.body.dataset.theme = lips.getContext().theme
})
// same, but the callback receives the current values
lips.useContext([ 'theme', 'user' ], ctx => {
console.log( ctx.theme, ctx.user )
})
stop() // unsubscribe
Context is for genuinely cross-cutting values — theme, session, locale. Passing data down as inputs stays cheaper to reason about, and is just as fast.
Macros
A macro is a template fragment that is inlined at compile time. There is no component instance, no input wiring, and no lifecycle — the macro body becomes part of the calling template.
const toolbar = {
macros: `
<macro [icon, label, active] name="tool">
<li class=(active ? 'tool on' : 'tool')>
<i class="icon-{icon}"></i>
<if( label )><span class="label">{label}</span></if>
</li>
</macro>
<macro [label, value, required] name="field">
<div class="field">
<label>{label}{required ? ' *' : ''}</label>
<input value=value required=required/>
</div>
</macro>`,
default: `
<ul class="toolbar">
<tool icon="pen" label="Pen" active/>
<tool icon="hand"/>
</ul>
<field label="Name" value=state.name required/>
<field label="Phone" value=state.phone/>`
}
Arguments
Names declared in brackets become variables in the macro body. Anything not declared is still
passed — reach it through arguments:
<macro [type, key] name="option">
<li data-type=type data-key=key on-click( pick, type, key, arguments )>
{arguments.label}
</li>
</macro>
Undeclared arguments are undefined: falsy in a condition, attribute-removing, and rendered
as an empty string.
Spread arguments
Call-site attributes are applied in source order, so spreads and explicit values override each other left to right, exactly like a JS object literal:
<!-- explicit `icon` wins -->
<tool ...state.defaults icon="pen"/>
<!-- the spread wins -->
<tool icon="pen" ...state.defaults/>
This makes the common list pattern read naturally:
<for [key, each] in=state.tools>
<tool key=key ...each/>
</for>
Keys that disappear from the spread object are removed on update.
Macros vs components
| Macro | Component | |
|---|---|---|
| Cost | inlined, zero runtime | an instance with its own state |
| State | none — it uses the caller’s scope | its own state |
| Lifecycle | none | full lifecycle |
| Recursion | not allowed (LIPS-C009) |
allowed |
| Scoped styles | no | yes |
Reach for a macro when you are repeating markup. Reach for a component when the thing has behaviour or state of its own.
Reactivity primitives
The signal core is exported, so you can use it outside a template — for stores, derived values, or glue with non-Lips code.
import { signal, effect, untrack, reactive } from '@lipsjs/lips'
signal( value )
Returns [ read, write, touch ].
const [ count, setCount, touchCount ] = signal( 0 )
count() // → 0
setCount( 5 ) // notifies subscribers
touchCount() // notify without changing the value
effect( fn )
Runs fn immediately, tracks every signal it reads, and re-runs when any of them changes.
const stop = effect( () => {
console.log('count is', count() )
})
stop.dispose()
Dependencies are re-tracked on every run, so a branch that stops reading a signal stops subscribing to it.
reactive( object, deep? )
Wraps a plain object in a per-key signal store.
const store = reactive({ user: null, items: [] })
effect( () => console.log( store.items.length ) )
store.items = [ 1, 2, 3 ] // logs 3
Pass deep: true to track nested mutation — including Map and Set contents:
const store = reactive({ layers: new Map() }, true )
effect( () => console.log( store.layers.size ) )
store.layers.set('a', {}) // logs 1
untrack( fn )
Reads without subscribing.
effect( () => {
const shown = visible() // tracked
const all = untrack( () => items() ) // not tracked
})
Component state, input and context are all built on these primitives — nothing in a
template has access to machinery your own code cannot use.