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 four 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 } from '@llui/dom'
type State = {
text: string
items: string[]
}
type Msg =
| { type: 'setText'; value: string }
| { type: 'add' }
| { type: 'remove'; index: number }
const TodoApp = component<State, Msg, never>({
name: 'TodoApp',
init: () => [{ text: '', items: [] }, []],
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, state.text] }, []]
case 'remove':
return [{ ...state, items: state.items.filter((_, i) => i !== msg.index) }, []]
}
},
view: ({ send, text, each }) => [
div({ class: 'app' }, [
div({ class: 'input-row' }, [
input({
type: 'text',
value: (s) => s.text,
onInput: (e) => send({ type: 'setText', value: (e.target as HTMLInputElement).value }),
onKeydown: (e) => { if (e.key === 'Enter') send({ type: 'add' }) },
placeholder: 'Add item...',
}),
button({ onClick: () => send({ type: 'add' }) }, [text('Add')]),
]),
...each({
items: (s) => s.items,
key: (_item, i) => i,
render: ({ item, index, send }) => [
div({ class: 'item' }, [
text(() => item()),
button({
onClick: () => send({ type: 'remove', index: index() }),
}, [text('x')]),
]),
],
}),
]),
],
})
mountApp(document.getElementById('app')!, TodoApp)
Core Concepts
Reactive Bindings
In view(), arrow functions create reactive bindings — they re-evaluate whenever the relevant state changes:
// Static (evaluated once):
div({ class: 'container' }, [...])
// Reactive (updates when state changes):
div({ class: (s) => s.isActive ? 'active' : 'inactive' }, [...])
text((s) => `Count: ${s.count}`)
Structural Primitives
LLui provides three structural primitives for conditional and list rendering:
show— render/remove nodes based on a boolean conditionbranch— switch between named views based on a state valueeach— render a list of items with keyed reconciliation
view: ({ send, text, show, branch, each }) => [
// show: boolean toggle
...show({
when: (s) => s.isVisible,
render: () => [div({}, [text('Visible!')])],
}),
// branch: multi-case switch
...branch({
on: (s) => s.page,
cases: {
home: () => [text('Home page')],
about: () => [text('About page')],
contact: () => [text('Contact page')],
},
}),
// each: keyed list
...each({
items: (s) => s.todos,
key: (todo) => todo.id,
render: ({ item, send }) => [
div({}, [
text(() => item.label()),
button({ onClick: () => send({ type: 'remove', id: item.id() }) }, [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
LLui supports two levels of composition:
Level 1 — View functions (default): A module exports update() and view() functions. The parent owns state; the child operates on a slice.
Level 2 — child() (opt-in): Full component boundary with own bitmask, update cycle, and scope tree. Use for library components or 30+ state paths.
// Level 1: view function
function todoItem(item: Accessor<Todo>, send: (msg: Msg) => void): Node[] {
return [
div({ class: 'todo' }, [
text(() => item.label()),
button({ onClick: () => send({ type: 'toggle', id: item.id() }) }, [text('done')]),
]),
]
}
Dev Server
npx vite
Open http://localhost:5173. Changes hot-reload via HMR.