Components
A component is a plain function, (props, tools) => html`...`,
and there are exactly two ways to use one:
// 1. By value: the tag IS the function; no registration, tracked by
// imports and refactors
html`<${Card} title="Hello" total="${count}" />`
// 2. By name: register once, use anywhere (case-sensitive)
import { setComponents } from 'lemonadejs';
setComponents({ Card, Modal });
html`<Card title="Hello">...</Card>`
No classes, no this, no self, no hook ordering rules. A component
used twice is just called twice; there is no identity to manage.
Design context
A few mechanical choices matter for composition:
- Registration. Components must be findable at render time, by function reference or by registered name. A misspelled or unregistered name should fail loudly, not render empty and silent.
- Prop types. HTML attributes are strings. Passing an object or a live state across a component boundary requires an escape mechanism; forgetting it produces
"[object Object]"at the receiver, silently. - Children scope. Content placed between component tags can bind to the parent’s state or the child’s, and the answer must be unambiguous and consistent.
v6 keeps one small rule set for these and makes violations loud.
How LemonadeJS solves it
Two forms, identical semantics. By value (<${Card} />) needs no
registration and survives renames. By name (<Card />) reads better in
large templates: register with setComponents({ Card }). Names must
start with a capital letter and match exactly. An unknown or
misspelled name fails at mount with LJS-104 and the registration hint,
never an empty render.
The prop rules (all of them):
- Literal attribute text → string. No guessing, no auto-casting; declared coercion exists, but only where it is declared: in a contract.
$→ passed by reference, untouched. States stay live across the boundary, callbacks stay callable, objects and arrays are never stringified.- A bare
flag→true. - Props are read-only (frozen in dev). Data flows up through
callback props, never by mutating the child. Callback names are
lowercase (
onsave,onitemclick; see Events).
Snapshot vs live is the caller’s choice, with the same syntax as everywhere else. Pass the state to keep it live; pass its value to snapshot it:
html`<${Card} total="${count}" />` // live: Card re-renders with count
html`<${Card} total="${count.value}" />` // snapshot: today's number, frozen
One precision for components inside a re-running expression, a list or a branch: each run re-delivers the plain values, and the engine patches the living instance (new values flow into its prop states; setup never re-runs) instead of rebuilding it. Frozen-at-build applies to the template’s one-time construction; entries in lists stay current. The mechanics, and what still counts as a structural rebuild, are in Lists.
Children are parent-scoped. The nodes between the tags arrive as
props.children, already materialized in the parent’s scope, so their
$ bind to the parent’s states:
const Card = (props) => html`<div class="card">
<h3>${props.title}</h3>
${props.children}
</div>`;
html`<${Card} title="Inbox">
<p>${count} unread</p> <!-- count is the PARENT's state -->
</${Card}>`
Imperative surfaces use the ref convention. The component calls
props.ref?.({ open, close }) and the parent holds the api. With a
contract, the api becomes a declared, verified surface that also flows
through React refs automatically.
Named or scoped slot patterns have no direct v6 equivalent. The answer is a render prop: pass a function returning a view. This is a convention, not a syntax.
In practice
A context menu is a stack of Modals. The Studio contextmenu does not implement positioning, auto-adjusting or layering: every open menu level is a headerless, auto-adjusting Modal, and the stack is an array mapped to views:
const levelView = (lvl) => html`<${Modal} key="${lvl}" header="${false}"
position="absolute" top="${lvl.top}" left="${lvl.left}" focus="${false}" autoadjust>
<ul class="lm-contextmenu-list" role="menu">${lvl.options.map(itemView)}</ul>
</${Modal}>`;
return html`<div class="lm-contextmenu">
${() => levels.value.map((lvl, li) => levelView(lvl, li))}
</div>`;
The list re-runs every time a submenu opens or closes, and the surviving
Modal entries are patched in place, not rebuilt. key="${lvl}"
keys each level by its own object, and the engine flows changed values
into the living instances (Lists is the full story). This
matters here for a visible reason: a rebuilt Modal would re-run
auto-adjust and move the parent menu when a submenu opens. An earlier
version of this component worked around the rebuild with a WeakMap
view cache; live patching made the cache unnecessary and the workaround
was deleted from the source.
Any block can live in a datagrid cell. The datagrid’s column
render is a render prop returning a string or an html view. From
the shipped demo, a working Switch inside a cell of a 100,000-row grid:
{
name: 'active', title: 'Active', align: 'center',
render: (value, row) =>
html`<${Switch} checked="${!!value}" size="small"
onchange="${(on) => { row.active = on; rows.touch(); }}" />`,
}
The cell view composes a full contract component; the onchange
callback mutates the caller’s own row and notifies. The
state model and the composition model are the same model.
Reference
import { setComponents } from 'lemonadejs';
setComponents({ Card, Modal }); // by name: capitalized, exact match, LJS-104 if unknown
html`<${Card} ... />` // by value: no registration, same props handling
// prop rules
title="Hello" // → string
total="${count}" // → by reference: state stays LIVE
total="${count.value}" // → snapshot of today's value
disabled // → true
onsave="${(v) => ...}" // → callback prop (lowercase, LJS-305)
// children and api
<${Card}>...</${Card}> // → props.children, bound to the PARENT scope
props.ref?.({ open, close }) // expose an imperative api to the caller