Getting Started
Installation
mkdir my-app && cd my-app
npm init -y
npm install @llui/dom @llui/effects
npm install -D @llui/vite-plugin vite typescript
Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite'
import llui from '@llui/vite-plugin'
export default defineConfig({ plugins: [llui()] })
HTML Entry Point
<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Your First Component
Every LLui component has five parts:
- State — a plain, JSON-serializable object
- Msg — a discriminated union of all possible events
init— returns[initialState, initialEffects]update— receives current state and a message, returns[newState, effects]view— runs once at mount time, returns DOM nodes with reactive bindings
// src/main.ts
import { component, mountApp, div, button, input, text, each } from '@llui/dom'
type Item = { id: number; label: string }
type State = {
text: string
items: Item[]
nextId: number
}
type Msg = { type: 'setText'; value: string } | { type: 'add' } | { type: 'remove'; id: number }
const TodoApp = component<State, Msg, never>({
name: 'TodoApp',
init: () => [{ text: '', items: [], nextId: 1 }, []],
update: (state, msg) => {
switch (msg.type) {
case 'setText':
return [{ ...state, text: msg.value }, []]
case 'add':
if (!state.text.trim()) return [state, []]
return [
{
...state,
text: '',
items: [...state.items, { id: state.nextId, label: state.text }],
nextId: state.nextId + 1,
},
[],
]
case 'remove':
return [{ ...state, items: state.items.filter((it) => it.id !== msg.id) }, []]
}
},
// The bag is `{ state, send }`. Element/structural helpers are imports.
view: ({ state, send }) => [
div({ class: 'app' }, [
div({ class: 'input-row' }, [
input({
type: 'text',
value: state.at('text'),
onInput: (e) => send({ type: 'setText', value: (e.target as HTMLInputElement).value }),
onKeyDown: (e) => {
if ((e as KeyboardEvent).key === 'Enter') send({ type: 'add' })
},
placeholder: 'Add item...',
}),
button({ onClick: () => send({ type: 'add' }) }, [text('Add')]),
]),
each(state.at('items'), {
key: (it) => it.id,
render: (item) => [
div({ class: 'item' }, [
text(item.at('label')),
button({ onClick: () => send({ type: 'remove', id: item.at('id').peek() }) }, [
text('x'),
]),
]),
],
}),
]),
],
})
mountApp(document.getElementById('app')!, TodoApp)
Core Concepts
Reactive Bindings
The view bag carries state, a Signal<State>. A reactive slot is a signal — slice
with .at('field'), derive with .map(fn). A static value is plain. An event handler is a
plain function. Read a one-shot value with .peek() (handlers and effects only).
// Static (evaluated once):
div({ class: 'container' }, [...])
// Reactive (updates when state changes):
div({ class: state.at('isActive').map((a) => (a ? 'active' : 'inactive')) }, [...])
text(state.at('count').map((n) => `Count: ${n}`))
Structural Primitives
LLui provides structural primitives for conditional and list rendering. They are module
imports from @llui/dom:
show— render/remove nodes based on a truthy conditionbranch— switch between named views based on a state valueeach— render a list of items with keyed reconciliation
import { show, branch, each, div, button, text } from '@llui/dom'
view: ({ state, send }) => [
// show: truthy toggle — the truthy arm gets the narrowed signal
show(state.at('isVisible'), () => [div([text('Visible!')])]),
// branch: keyed on a string/number signal's value
branch(state.at('page'), {
home: () => [text('Home page')],
about: () => [text('About page')],
contact: () => [text('Contact page')],
}),
// each: keyed list — render gets a per-row `item` signal
each(state.at('todos'), {
key: (todo) => todo.id,
render: (item) => [
div([
text(item.at('label')),
button({ onClick: () => send({ type: 'remove', id: item.at('id').peek() }) }, [text('x')]),
]),
],
}),
]
Effects
Side effects are data — plain objects returned from update(). The runtime dispatches them:
import { http, cancel, debounce, handleEffects } from '@llui/effects'
type Effect = ReturnType<typeof http> | ReturnType<typeof cancel> | ReturnType<typeof debounce>
const App = component<State, Msg, Effect>({
// ...
update: (state, msg) => {
switch (msg.type) {
case 'search':
return [
{ ...state, query: msg.value },
[
cancel(
'search',
debounce(
'search',
300,
http({
url: `/api/search?q=${encodeURIComponent(msg.value)}`,
onSuccess: (data) => ({ type: 'results' as const, data }),
onError: (err) => ({ type: 'error' as const, err }),
}),
),
),
],
]
// ...
}
},
onEffect: handleEffects<Effect, Msg>().else(({ effect }) => {
console.warn('Unhandled effect:', effect)
}),
})
Composition
The default composition model is view functions: a module exports a function that
takes a signal handle for its slice plus the parent's send. The parent owns all
state; the child renders a slice.
import { div, text, button } from '@llui/dom'
import type { Signal, Send } from '@llui/dom'
// A reusable row — takes a per-row signal, not an accessor callback.
function todoItem(item: Signal<Todo>, send: Send<Msg>): Renderable {
return [
div({ class: 'todo' }, [
text(item.at('label')),
button({ onClick: () => send({ type: 'toggle', id: item.at('id').peek() }) }, [text('done')]),
]),
]
}
Library components from @llui/components use a state-machine + connect convention: the
parent owns the slice, delegates to the component's pure update, and routes its messages
through the parent's own Msg union (send({ type: 'dialog', msg })). See the
Composition patterns guide.
For embedding a genuinely independent app (third-party bundled widget, an independent
effect lifecycle), the escape hatch is a full child() boundary (or lazy() to load one
asynchronously). Don't reach for it to "isolate a complex component" — the chunked-mask
reactivity gates each binding precisely regardless of nesting depth.
SSR
LLui supports server-side rendering via @llui/vike. The runtime's
view() runs identically on the server (producing HTML strings) and
on the client (hydrating existing DOM). See the SSR cookbook recipes
and @llui/vike API reference for setup details.
Dev Server
npx vite
Open http://localhost:5173. Changes hot-reload via HMR.