This tutorial teaches tutuca step by step. It starts with the simplest component and ends with macros and async requests. Each section contains live code that you can edit. Change an example and press Ctrl+Enter (or Cmd+Enter on macOS) to see your changes. Later sections build on earlier sections. Read them in order.
Notation Reference
Tutuca templates use prefix characters to show different kinds of references. You will see them throughout this tutorial:
-
.name— a component field (e.g..count). One level only — paths like.user.nameare not supported anywhere. To read a nested value, render the child as a component (<x render=".user">then@text=".name"inside), add a method that returns the value, or expose it with@enrich-with. -
$name— a method call with no arguments (e.g.$inc). Tutuca calls the method and uses its return value..nameand$nameare not interchangeable; the linter reports a mismatch. -
@name— a local binding from iteration or scope enrichment (e.g.@key,@value) -
^name— macro parameter (e.g.^label) -
*name— dynamic binding (e.g.*theme,*entries) -
.seq[.key]— access to an item in a sequence or map. Tutuca resolves the inner expression as a key into the outer collection (e.g..byKey[.currentKey])
Basics
Minimum Viable Component
The simplest component has no fields, no view, and no logic:
component({}). An empty object is the minimum for a
tutuca component. This component does not render anything useful.
But it shows that all parts are optional.
Static View Component
A view gives the component something to render. Here the
view has no dynamic content. It is static HTML wrapped in the
html tagged template. This works. But for static content
with no state and no interactivity, use
macros.
The html tag helps editors highlight and format the
content as HTML. The css tag does the same for styles.
Both tags are optional: you can use plain strings instead.
Text Rendering
Put @text on an existing element to add text before its
other children, or use
<x text=".field"></x> as a standalone text
node with no extra DOM element. The value can be a field reference
like .str. Tutuca then reads the str field.
Or it can be a method call like $getStrUpper. Tutuca
then calls the method and shows its return value. All value types are
supported: strings, numbers, booleans, and null.
Mental Model
Three rules explain everything that follows. The application state is
a single immutable root value. Every component
instance is a node in that tree. The state is deeply frozen between
transactions. The view is a pure function of the value:
the same value always gives the same DOM. Every event handler receives
a mutable draft as its first argument. The framework
produces the new value and puts it into the tree. The renderer reuses
cached subtrees by === identity. Every dispatched handler
receives an Immer draft first, while this
stays the immutable current instance.
In practice a click flows like this: click → handler changes its draft → Immer produces a new value → the framework puts the new root in place → the renderer compares with the previous frame and updates the DOM. You never subscribe or unsubscribe. There is no central store. Handlers do not change state in place. The rest of this tutorial teaches the syntax for these handlers and views. The model under them is what you just read.
State and Updates
Field Types
A component declares its state in fields, as a map of
name to default value. Tutuca infers the field's type from
the default: 0 is a number, "hi" a
string, true a boolean, [] an Array,
{} a plain object, new Map() a map,
new Set() a set, and null the
any type. Handlers update fields with normal JavaScript
operations on their first draft argument.
-
Assign scalars and object members directly:
draft.count++,draft.profile.name = value. -
Toggle booleans with
draft.open = !draft.open. -
Arrays use
push,splice, and indexed assignment. -
Native Map and Set use
set/addanddelete. Immer records all of these operations.
Return nothing to commit the changed draft. Return another component instance to replace that component. If nothing changed, tutuca keeps the original identity. Generated field setters are not part of the API. Define only the named handlers that your view needs.
component({
name: "Profile",
fields: {
name: "", // string
age: 0, // number
active: true, // boolean
tags: [], // native Array
},
receive: {
setName(draft, name) { draft.name = name; },
toggleActive(draft) { draft.active = !draft.active; },
addTag(draft, tag) { draft.tags.push(tag); },
},
});
Methods vs Receive Handlers
Tutuca separates read-only computations from mutation handlers.
methods are reads without arguments, used in value
slots. receive holds the draft-first event and message
handlers:
-
$name(with a leading$) calls a read-onlymethods.namefrom a value slot. name(bare) callsreceive.name.
Every @on.* uses the bare form. It resolves only in
receive; tutuca rejects $method there.
When the linter finds the old dual-role pattern, it tells you to move
the same-named method into receive.
Attribute Binding
Use the :attr syntax to bind component state to an HTML
attribute. For example, :value=".str" binds the
str field to the value attribute of the
input. Template interpolation also works in bindings:
:title="$'Content is {.str}'" builds a dynamic title
string.
When the user types,
@on.input="setStr e.value" calls the draft-first
setStr handler with the current value of the input. For
the number input, setRawNumber parses and checks the
value before it assigns to the draft.
Event Handling
The + button uses @on.click="inc". This
names the inc receive handler. The -
button names dec in the same way. Both handlers change
the first draft argument.
Computed methods never receive a draft. They cannot be event handlers. So calls on component instances are read-only by design.
Conditional Display
@show=".field" hides the host element when the field is
falsy. @hide=".field" hides it when the field is truthy.
The element still renders in the DOM; only its visibility changes. Use
this for regions that toggle fast, when mount cost does not matter.
@show and @hide can also appear directly on
the <x> render ops
(render, render-it,
render-each, text). They do not toggle a
host element. They wrap the produced node in the same guard with no
extra DOM. For example,
<x render-it @show=".isOpen"></x> equals
render-it wrapped in
@show=".isOpen". When both appear on one element, the
first attribute in source order becomes the outer wrapper.
The value can be a field, a built-in boolean predicate applied to one
(empty?, truthy?, falsy?,
null? — written predicate-first, e.g.
@hide="empty? .items"), or a methods entry:
@show="$canSubmit" calls
methods.canSubmit() with no arguments and uses its return
value. Use a method when a condition combines several fields; the
predicates cover only single-field checks.
Event Modifiers
Modifiers filter events. A handler fires only under the specified
conditions. @on.keydown+send="setLastSentSearch value"
fires only when the user presses Enter.
@on.keydown+cancel="resetQuery" fires only on Escape.
Append modifiers with +; you can combine them (e.g.
+ctrl+send).
Two modifiers act on the event itself. They do not filter:
+prevent calls preventDefault() and
+stop calls stopPropagation(). Both work on
any event. Both run only after the filter modifiers on the same
handler pass. So
@on.keydown+send+prevent prevents the default on Enter
and leaves every other key unchanged.
The example below also uses
@show="truthy? .lastSentSearch" to show the search
result conditionally — truthy? is one of the boolean
predicates introduced in
Conditional Display
above.
Conditional Attributes
@if.class=".isActive" tests the boolean field. Then
@then="'btn btn-success'" or
@else="'btn btn-ghost'" sets the class. The same pattern
works for any attribute: @if.title /
@then.title / @else.title sets the title
conditionally. Single quotes inside the value ('...')
mark a string literal.
$toggleIsActive in the example is a small explicit
handler that negates the draft field. The value for @if
can also be a method — same rule as @show. This is
useful when the predicate combines several fields.
With one @if directive, @then and
@else do not need to specify the attribute name; they
infer it from @if.<attr>. With several
@if directives on one element, the additional
@then and @else must name the attribute
explicitly (e.g. @then.title,
@else.title). HTML does not allow duplicate attribute
names, so the parser would drop a second unsuffixed
@then= before tutuca reads it.
Tabbed UI
A tabbed UI uses the two previous sections together. The active tab
is one string field, tab. Everything else derives from
it. There are no per-tab boolean flags and no separate component per
tab.
The comparison uses equals?, a built-in boolean
predicate. The unary predicates in
Conditional Display
(empty?, truthy?, falsy?,
null?) take one value. equals? takes two
values, written predicate-first: here a field and a string literal,
as in equals? .tab 'overview'. Each tab panel uses it in
@show, so only the matching panel is visible. Each tab
button uses it in @if.class —
@then="'tab tab-active'" highlights the active button,
@else="'tab'" styles the others.
A click calls $setTab 'overview'. This explicit handler
assigns the tab draft field. Single quotes mark string
literals both in the equals? comparison and in the
handler argument.
No Dotted Paths in Values
A tutuca expression resolves a single name on
this. Writing @text=".user.name",
:value=".item.title", or
@show=".item.isOpen" does not navigate. The
parser never walks a dotted path where a value is read. Remember this
rule. When the value is one level deeper, you have three options:
-
Render the child as a component —
<x render=".user">and read.nameinside the view of the child. Use this when the nested value is, or can be, its own component. -
Add a method that returns the value —
userName() { return this.user.name; }, then use@text="$userName". Use this for one-time derivations or formatting. -
Use
@enrich-with— expose computed values as@-prefixed bindings to a subtree. The values do not go on the component. See Scope Enrichment.
Exceptions: @each / render-each accept only
.field or *dynamic (not a
$method). <x render> expects a
component instance — for a derived list, store it in a field or
use @when with alter.
Quoting & String Literals
The tutuca expression parser depends on context. The rules for static strings differ from the rules for dynamic expressions. Learn these rules once; they prevent most syntax errors.
-
'string'— single-quoted string literal. Works anywhere a value is allowed (@then="'btn ok'",:label="'Sale'"). -
String template:
:class="$'btn {.kind}'"— works in:attr=,@text, macro dynamic attrs, anywhere a string template is allowed. The$'…'prefix marks a template;{...}holds the interpolations. -
Text without quotes or braces:
:class="flex gap-3"— this does not work. The parser returnsnull. Single-quote the text or add a{...}part. -
Bare identifier:
dec— valid only as an event handler name (the first slot of@on.*). Never valid as a value, and never as a handler argument.
Plain HTML attributes are static strings as written
(class="card"). Macro parameters passed without
: are static strings too (label="Sale").
The quoting rules apply only when the prefix : turns the
value into an expression, or inside @if /
@then / @else / @text / event
handler arguments.
Collections
List Iteration
@each=".items" iterates over the items
field and repeats the element for each entry. Inside the loop,
@key is the current index (for Lists) or key (for Maps),
and @value is the current item. These are local bindings
accessed with the @ prefix: @text="@key" and
<x text="@value">.
List Filtering
Add @when="filterItem" next to @each. Tutuca
then calls alter.filterItem(_key, item) for each entry.
If it returns false, tutuca skips the item. Functions in
the alter object have this bound to the
component state, so filterItem here reads
this.query and filters items by the current search
string.
If the value used method syntax (@when="$filterItem"),
tutuca would call a method instead. Like input, the
alter section exists for organization only.
Iteration Enrichment
@enrich-with="enrichItem" calls
alter.enrichItem(binds, _key, item) for each iteration
step. By mutating the binds object (e.g.
binds.count = item.length), you create new local bindings
accessible in the template as @count. This lets you
derive and display per-item values without adding them to the
component's state.
Shared Iteration Data
@loop-with="getIterData" calls
alter.getIterData(seq) once before the loop starts. Its
return value (here { totalChars, queryLower }) goes as
the third argument to both filterItem and
enrichItem. This prevents duplicate work:
queryLower is computed once, not per item, and
totalChars comes from the full sequence before any
filtering.
Filter and Paginate
A filter together with pagination is common, but order matters:
@when filters within the slice
@loop-with already cut. So a page can show fewer rows
than its size, and the page count reflects the unfiltered total. To
filter before paging, return keys from
@loop-with: an ordered list of the matching rows'
original keys for the current page.
The handler owns the whole pipeline: filter, sort, then slice. Because
keys are the original indices,
@key keeps the identity of each row: editing or deleting
a row on page 2 of a filtered view hits the correct item. A
keys return is authoritative, so the renderer does not
apply @when again. Each row here is its own
<x render-it> component, so its fields are
self-contained two-way bindings.
The page controls sit outside the loop, so they cannot see its
per-loop data. The pattern below splits the work in two: a scope
@enrich-with on the <section> does one
counting scan and publishes the clamped page and labels as
@-bindings for the controls to read. The
@loop-with handler then takes a context,
(seq, { lookup, filter }). It reads that clamped
page via lookup, reuses the declared
@when via filter, and collects only the
current page's keys — it scans only as far as needed.
Scope Enrichment
Use @enrich-with="enrichScope" on an element without
@each. The alter function then returns an
object (here { len, upper }) whose keys become
@-prefixed bindings for all child elements in this view.
(They reach plain DOM descendants, not a rendered child component;
a <x render> boundary starts a clean namespace.)
Inside the enriched <div>,
@len and @upper are available next to the
regular field bindings. This helps you put derived values into a
section of the template without storing them in component state.
Rendering Components
Rendering a Child Component
Until here, components were self-contained: their views render only
their own fields.
<x render=".field"> renders a child component held
in a field of the current component. The child draws its own view from
its own state, scoped to its field.
<x render-it> works the same way, but only
inside an iteration (@each or
render-each). It renders the component instance of the
current iteration.
A rendered child gets a clean namespace: the parent's
@ bindings (from @each iteration or
@enrich-with) and scope enrichments do
not
cross into it. The child sees only its own fields. To pass a value
across that boundary, use a
dynamic binding (*name).
With children, the value tree becomes a real tree: each
<x render> is a parent → child edge. A click
inside the grandchild produces a new grandchild. That produces a new
child. That produces a new root (see
Mental Model).
Multiple Views
A component defines its default template in view and
alternate templates in views: { name: html`...` }.
<x render=".item"> renders the default
("main") view. Adding as="edit" selects the
named "edit" view instead. Both render the same
Entry instance — the main view shows read-only
text, while the edit view shows input fields bound to the same fields.
Collection Item Access
<x render=".byIndex[.currentIndex]"> renders the
component at position .currentIndex in the
byIndex list. The bracket syntax resolves the inner
expression as a key into the outer collection.
.byKey[.currentKey] does the same for a
native Map: it looks up the entry by string key.
The range slider updates currentIndex, and the select
dropdown updates currentKey. The rendered component then
updates.
Dynamic View Switching
@push-view=".view" puts a view name onto the rendering
stack. When tutuca renders a component, it looks for the view name
from the top of the stack down. It uses the first defined match; if
none matches, it renders the default "main" view. The
view stack applies to any component rendered recursively under the
@push-view directive, not only direct children.
Change the view field between "main" and
"edit". Every Entry item then switches
between read-only and editable mode at once. The
@when="filterItem" attribute on
<x render-each> filters items in the same way
@when does on @each.
Component Styles
style: css`...` applies to one component and view
combination: the same class name .mine can have
different styles in different views. commonStyle is
shared by all views of the same component.
globalStyle is added globally with no scoping.
Both style and commonStyle are wrapped in a
component-scoped selector ([data-cid="N"]{ … }). Bare
declarations with no selector
(e.g. color: red;) go directly inside that wrapper, so
they style the root element of the component; you
need no extra wrapper selector. Rules written with a selector
(.mine { … }) target descendants instead.
View "two" defines its own style that overrides the
default. Notice .mine is red in the main view but
orange and underlined in view two. The root component renders all
three views side by side using
<x render=".value" as="viewName">.
Statics
statics: { ... } puts methods on the component
class, not on instances. Call them as
Comp.fromData(...) — component({...})
returns the class itself, with the component's metadata attached
behind a well-known symbol. Inside a static,
this is the class itself, so
this.make({...}) calls the generated constructor that
component({...}) makes for every component.
The most common use is a fromData factory that builds an
instance from plain JS data (e.g. JSON loaded from disk) and
constructs children recursively. This is what the recursive-tree
example in the next section uses: it turns a nested object literal
into a fully constructed TreeItem with
arrays of TreeItem children. Statics are not part of
any lifecycle. They are plain class methods, called by the host
application or by another static.
A component binds to a scope at
registerComponents time, and that scope owns it
(and therefore its scope-bound make /
statics). So a given component object is live in one scope at
a time. Note this case: a static that builds a
different child type names the imported const directly
(e.g. TreeItem.fromData(v)) and hardcodes that child's
original scope. That works in a single-scope app; if you
run the same definition in separate registries, resolve the
child through the caller's scope instead —
this.scope.lookupComponent("TreeItem").fromData(v).
Recursion into the same type needs no lookup: just call
this.fromData(v) / this.make(...).
Recursive Components
TreeItem renders its children with
<x render-each=".items">, where
.items is a list of more TreeItem
instances: the component renders itself, recursively. Each
render creates a new VDOM subtree for that branch, and tutuca stops
descending when an item has an empty
items list. TreeItem.fromData (a
static, see the previous section) builds the nested structure from
plain data. The component style uses the CSS pseudo-element
:before to show folder / file icons based on class
names set with @if.class.
Component Communication
Components do not need to talk to each other; many apps let parents read the state of children directly. But sometimes one component must tell another to do something. Tutuca offers two channels, and one question separates them: does the sender know who handles this? If yes, it sends a message. A message reaches one component and stops. If no, it raises an intent. An intent walks a route until something answers. Dynamic bindings cover the third case: a descendant needs read-only access to an ancestor's value without passing it through every component in between.
Send / Receive
ctx.send(name, args) calls a named handler in
receive: { ... }. Called bare, it targets the current
component. Prefixed with ctx.at, it targets any
component reachable by path. ctx.at returns a path
builder with .field(name), .index(name, i),
and .key(name, k). Chain calls to descend further, then
end with .send(name, args).
When to send (vs raise an intent).
Send delivers a message to a specific target by path. Use it
when one component needs to address another by name (a form tells its
email field to focus, a list tells item 3 to enter edit mode), or to
call a receive handler on self from several call sites
without duplicating its body. When you do not know who should answer,
raise an intent instead and let a route find someone (next
section).
In the example below, the form sends
ctx.at.field("status").send("flash", [text]) to its
sibling Status child on submit. It sends
ctx.send("clearDraft") to itself to reuse the same reset
handler from a second call site. The
Status component owns its own state and knows nothing
about who sent the message: the path is the only coupling.
Intents and Routes
When to raise an intent: handle the action locally if
the current component owns the state needed to respond. Raise an
intent when you do not know who should answer: a list
item's remove that must reach the list owning the items,
or something an ancestor may want to record (selection, logging,
analytics).
An intent carries a route: a list of legs saying
where to look. "dyn" walks the dispatch path from the
sender's parent up to the root; "lex" walks the
handlers registered on the scope (async work lives there, in the
next section). With no route it takes both, in that order. The verb
does not decide which scope answers; the route does, and it is
written at the call site where the decision is.
The recursive tree.js example from
Recursive Components shows this.
When a node is clicked, onItemClick calls
ctx.intent("treeItemSelected", [this], { route: ["dyn"] }).
Tutuca walks the component path from the sender's parent toward the
root and, at each ancestor, looks for a matching
intent.treeItemSelected handler; in the tree example the
TreeRoot at the top of the chain catches it and adds a
log entry at the front.
Intent handlers return a (possibly updated) instance of their own
component, just like methods and receive
handlers. One sentence governs the walk:
a reply ends the walk; running does not. A handler
that changes state and returns is an observer, and the intent
keeps going. One that calls ctx.reply(value) or
ctx.fail(error) answers and stops the walk.
ctx.stop() ends the walk with no answer. That single rule
removes the need for a separate "listener" bucket: an observer and an
answerer are the same construct with and without a reply.
The answer comes back to the sender as an ordinary message, in
the receive bucket, under one of three names:
<name>Ok with the result,
<name>Error with the error, or
<name>Unhandled (carrying the intent's own
arguments) when the route ran out with nobody claiming it. Each arm
takes a single payload, so no handler can be handed both a result and
an error and read the wrong one. Declaring these arms makes an
intent a request rather than a notification.
Declare none and tutuca simply drops the outcome.
Async Requests
Lifecycle note: tutuca has no built-in lifecycle. The
receive section is only a place to register named
handlers; nothing in the framework calls
receive.init automatically. The host application must
dispatch it (the tutorial harness calls
app.sendAtRoot("init") after
app.start() — that is what makes
init run in these playgrounds).
The init(ctx) handler in the receive section
runs when the application starts. It calls
ctx.intent("loadData", [], { route: ["lex"] }) to trigger
the async function registered in getIntentHandlers().
When the fetch completes, tutuca delivers the result to
receive.loadDataOk(res). If it threw, the result goes to
receive.loadDataError(err). If nothing was registered to
answer it, it goes to receive.loadDataUnhandled().
The component manages a loading state with
@show=".isLoading" and @hide=".isLoading".
The "Load Another Way" button raises the same intent on the
default route (["dyn", "lex"]): the ancestors
first, then the registered handlers. The answer arms do not change
with the route: the route says who answers, the arms say what to do
about it.
The async implementation sits outside the component in
getIntentHandlers(). This separation lets the same
component behave differently in production, in different test
cases, or in different apps: change only the
registered handler. A handler that has nothing to contribute returns
the exported PASS sentinel to decline, and the walk moves
on to the next one.
Dynamic Bindings
Dynamic bindings reach across the tree: a producer component
publishes a value, and any descendant can read it as
*name without the value being passed through every
component in between.
Best practice: keep state local to the component.
Use provide / lookup only when it is
genuinely the only solution: a value owned far away that a deep
descendant needs, and nothing in between should know about. Dynamic
bindings couple a consumer to a producer that may not be in scope. So
keep components as self-contained as possible: let a child
render the field it needs from its owner, and lift state only as far
up the tree as it needs to live.
A producer declares
provide: { entries: ".items" }: the field (or
seq-access) it wants to expose. Tutuca evaluates every
provide and puts it onto the stack automatically when
render enters the producer; there is no hook to opt in. A consumer
declares
lookup: [{ name: "entries", default: ".items" }]
— a list of the names it wants, not of who provides them
— then reads one as
*entries in the template (e.g.
@each="*entries"). When no producer is in the render
stack, tutuca uses the default expression (omit it to
get null).
Dynamic Render Targets
A *name dynamic var resolves to a value, so it works
anywhere a value is read, not just inside :style /
:class. In particular it can be a component-render
target: <x render="*name"> renders the component
the dynamic points at, as="edit" selects one of its
views, and @each="*name" iterates it when the dynamic
resolves to a sequence.
A provide value must be addressable: a
field or a .seq[.key] seq-access. It doubles as the
render-target and mutation path, so a method or constant gives a lint
error. A producer can therefore expose a single selected entry with
provide: { selected: ".items[.selectedKey]" }. There is
no *name[.key] form: the consumer never indexes a dynamic
var, it just reads the resolved value as *name. (A
lookup default, by contrast, is only a value
fallback; it may be any expression, including a constant.)
In the example below, Workspace exposes its
.sheet field as the dynamic active and
renders a deep tree (Workspace → Panel → Toolbar). Toolbar, far from the producer, consumes
active and renders <x render="*active">.
The rendered Sheet lives at
Workspace.sheet, so when you edit its title the mutation
goes directly there. Every state update in tutuca runs as a
transaction: a dispatched handler call that
swaps the new state tree in for the old one. Here the render path is
expanded to reconstruct the handler, but the transaction skips the
intermediate components and lands on Workspace.sheet.
The title echoed at the top proves it: it updates with every change.
Macros
Macros: Reusable Templates
Macros define reusable HTML fragments that expand in place.
macro({}, html`...`) takes a defaults object (empty here)
and a template. Export macros via getMacros() and
reference them in templates with the
<x:name> syntax. Unlike components, macros have no
state or lifecycle: they are pure template expansion. Use them for
repeated markup patterns.
The HTML parser lowercases custom tag names, so
<x:Card> is read as <x:card>.
Registry keys become lowercase on
registerMacros, so a capitalized const like
{ Card } registers under
card. Two different macros under the same
lowercased name cause a warning via console.assert.
Macros: Parameters
Macros take parameters with default values. The first argument to
macro() defines the defaults:
{ label: "'New'", kind: "'info'" }. Inside the macro
template, ^param references a parameter. For example,
@text="^label" displays the label value.
In the macro tag, a plain attribute like
label="Sale" passes a static string. No quotes are
needed; this is the same as regular HTML attributes. A dynamic
attribute (prefixed with :) takes an expression instead,
following the rules from
Quoting & String Literals:
:label="'Sale'" is a string literal,
:label=".status" a field reference.
Macros: Slots
<x:slot></x:slot> inside a macro template
acts as a placeholder for child content. Children placed inside
the macro tag replace the slot when the macro expands. Layout macros
use this: cards, panels, and containers wrap arbitrary content while
providing consistent structure and styling.
Macros expand inline into the calling component's template. So
@on.click="inc" inside a macro calls the host
component's receive.inc handler, never anything on the
macro itself (macros have no state or methods). This differs from
components: a component encapsulates its own state and handlers, while
a macro is only template expansion in the context of its
host component.
Macros: Named Slots
A macro can define several insertion points with named slots. Inside
the macro template,
<x:slot name="actions"> and
<x:slot name="footer"> mark named slots, while
<x:slot> (the same as
<x:slot name="_">) is the default slot.
Wrap content in
<x slot="name"> to target a specific named slot.
Children not wrapped in a named <x slot> go to
the default slot. This way macros define complex layouts with
several customizable regions.
Special Cases
Drag and Drop
Set draggable="true" to enable drag on each item.
data-dragtype declares the drag type of the
element (e.g. "my-list-item"), and
data-droptarget marks it as a valid drop zone.
During a drag, tutuca manages two runtime attributes:
data-dragging="1" is set on the source element while it
is dragged, and data-draggingover is set on the
current drop target with the value of the source's
data-dragtype. Use these as CSS attribute
selectors to style drag states. For example,
[data-dragging="1"] fades the source and
[data-draggingover="my-list-item"] highlights the
target. Tutuca removes both runtime attributes automatically when the
drag ends.
The drop handler receives @key (the target index),
dragInfo, and event (the raw DOM event).
dragInfo captures the rendering stack from when the
dragged element was rendered, so
dragInfo.lookupBind("key") returns the source item's
iteration index, or any other binding that was available at
that point (see Mental Model —
this is the same stack that resolves @key /
@value during render). The component
style (using the css tagged template) adds
visual feedback for dragging states, scoped to this component.
Web Components
Custom elements work as tags inside a component view, and any
CustomEvent they fire is reachable via
@on.<event-name>. The event's
detail is what the built-in value handler
arg resolves to, so an input handler signature like
onEmojiClick(detail) receives the picker's
detail object directly.
The example below imports
emoji-picker-element from a CDN and listens for its
emoji-click custom event. Put hyphenated event names
straight into the @on. attribute; no special quoting is
needed.
Pseudo-x (@x)
Tutuca's special operations (render,
render-each, render-it, text,
show, hide, slot) live on the
<x> tag. That works almost everywhere, but
the browser's HTML parser refuses to keep <x> (or
any unknown tag) as a child of certain elements.
<select> accepts only <option>,
<table> accepts only <tr> /
<tbody> / etc., and <tr> accepts
only <th> / <td>. Put a
<x render-each> inside any of those and the parser
silently removes it.
The solution: prefix the first attribute on a
legal child tag with @x. Tutuca treats that tag
as if it were <x> and reads the next attribute as
the special op. The host element itself is ignored; only the
special op runs.
The example below shows both common cases. The first parent
renders a <table> whose rows are themselves
components: inside <tbody>,
<tr @x render-each=".rows"> tells tutuca to render
one TableRow component per item. The second parent
renders a <select> whose options are components:
<option @x render-each=".options"> renders one
SelectOption per item. The same pattern works inside
<tr>, <colgroup>,
<dl>, <details>, or anywhere
else the parser would otherwise discard a <x> tag.
Raw HTML
@dangerouslysetinnerhtml=".content" sets the element's
innerHTML from the field value. The name is intentionally
strong (borrowed from React). It warns you: this directive bypasses
all text escaping. If the content comes from untrusted sources, it can
cause XSS attacks. Use it only when you control the HTML content or
have sanitized it. While this directive is active, the element's
children in the template are ignored.
Testing
Components have three testable layers:
methods (called directly from JS),
input handlers (template-attached event handlers),
and iteration handlers in the
alter block (used by @when,
@loop-with, and @enrich-with). The testing
tab in this playground runs the tests written in the same module as
the component; the CLI command
tutuca test <module> picks up the same tests.
Test Setup
A module joins testing by exporting
getTests({ describe, test, expect }).
expect is chai; describe and
test are tutuca's own subset of the common
Mocha/Jest-style API (no before / after /
beforeEach). Group tests by component with
describe(MyComp, () => { ... }). This tags the
suite, so tutuca test <module> MyComp picks it up.
Calling Methods and Input Handlers
Methods bind to the instance; call them directly:
MyComp.make().inc() returns the next instance. Input
handlers are plain functions stored on the component descriptor with
no this bound, so use .call to bind the
instance explicitly:
MyComp.input.dec.call(MyComp.make()). The arguments after
the instance are exactly what the template would have passed (e.g. the
resolved value / valueAsInt handler args).
Testing Iteration Handlers
The three iteration handlers in alter have distinct
signatures: when(key, value, iterData) filters,
loopWith(seq) runs once and produces shared
iterData, and
enrichWith(binds, key, value, iterData) changes each kept
item's bindings. this is the parent component instance in
all three.
Use collectIterBindings to test the whole pipeline
(filter + loop-data + enrichment). A working implementation ships
only in the dev build; the core tutuca build (the one
the README's CDN quick start imports) exports a no-op stub. In your
own project import it from "tutuca/dev", or run tests
through tutuca test, which redirects the bare
"tutuca" import to the dev build automatically. The
playgrounds on this page import it from "tutuca"
because their import map points that specifier at the dev build:
import { collectIterBindings } from "tutuca";
const c = MyComp.make({ items: [...] });
const r = collectIterBindings(MyComp, c, c.items, {
loopWith: "loopHandlerName", // optional
when: "whenHandlerName", // optional
enrichWith: "enrichHandlerName", // optional
});
// r is Array<{ key, value, ...enrichments }> — one entry per kept
// item, in iteration order.
Handler names refer to entries in MyComp.alter; unknown
names throw. The example below has computed methods, receive handlers, and an
iteration pipeline — all three are exercised by
getTests. The Test tab is selected
automatically (auto-run-tests) so you see the result on
load and after every Ctrl/Cmd+Enter.
Linter Reference
The tutuca CLI's lint command emits codes
for the categories below. Run it after each edit (the post-edit
recipe in the skill's core.md →
Verifying changes). It catches typos and broken references
before they reach a render. The example below triggers every
category on purpose; open the Lint tab on the right
to see them all detected.
The categories the linter reports, grouped as in the CLI reference:
-
Field references —
.fieldnot declared infields(FIELD_VAL_NOT_DEFINED). -
Method ↔ handler confusion — a
receivename referenced by a view but not declared, or a$methodwritten where an event handler name belongs (RECEIVE_HANDLER_NOT_IMPLEMENTED,EVENT_HANDLER_METHOD_NOT_ALLOWED). The linter knows the section each name lives in. -
Iteration helpers (
alter) —@when/@enrich-with/@loop-withname not inalter, or analterentry never used (ALT_HANDLER_NOT_DEFINED,ALT_HANDLER_NOT_REFERENCED). -
render-itoutside a loop —<x render-it>only works inside@each/render-each(RENDER_IT_OUTSIDE_OF_LOOP). -
Unknown event modifiers — e.g.
@on.click+badmodwhen only+ctrl/+cmd/+meta/+alt(and onkeydown:+send/+cancel) are recognized (UNKNOWN_EVENT_MODIFIER). -
Unparsable handler arguments — every
argument slot carries a sigil (
e.value,.field,@bind,$method,*dyn, or a literal). A sigil-less word — a component type included — fails to parse (BAD_VALUE). A handler that needs a component type asks for it withctx.lookupType("Name"). -
Duplicate attribute definitions — setting the
same attribute (e.g.
class) via a literal,:class, and@if.classon the same element. Only one wins; the linter flags the conflict (DUPLICATE_ATTR_DEFINITION). -
Unknown
@directive— e.g.@bogus="..."on an element. Catches typos in directive names like@show,@text,@on.event,@if.attr, etc. (UNKNOWN_DIRECTIVE). -
Unknown
<x>op — the first attribute on<x>(or pseudo-@x) is not a recognized op (render,render-it,render-each,text,show,hide,slot) (UNKNOWN_X_OP). -
Unknown
<x>attribute — extra attribute on a<x op>that the op doesn't consume and isn't a known wrapper (show,hide) (UNKNOWN_X_ATTR). -
Names registered with the app —
a component type named in
lookupis not registered, or a macro attribute is not declared in defaults (UNKNOWN_COMPONENT_NAME,UNKNOWN_MACRO_ARG). -
Dynamic bindings — a
*namewith nothing to resolve to, aprovidevalue that is not a path, an uppercaseprovidewhose value is not"self", or alookupthat nothing in scope provides and no registered path answers (DYN_VAL_NOT_DEFINED,PROVIDE_NOT_ADDRESSABLE,PROVIDE_TYPE_BAD_SHAPE,LOOKUP_NO_PROVIDER). Two components providing one name is not an error: the nearest one in the live render ancestry wins. -
Unreferenced declarations —
alterorlookupentries that no view ever uses (ALT_HANDLER_NOT_REFERENCED,DYN_ALIAS_NOT_REFERENCED). Hint level. Useful to catch dead code after a refactor.
What's Next
You now know all core features of tutuca. To see them working
together in realistic scenarios, see the
example apps on the home page:
a to-do list, a JSON editor, a recursive tree, and more.
The next step beyond this page is the storybook. Put your components
and examples in a co-located *.dev.js module (a dev-only
file exporting getComponents() and
getExamples()) and tutuca storybook serves
a live catalog of them with no setup. The
Storybook here is a live reference for
every feature of that format: sections, named views,
macros, per-example request mocks, lifecycle on hooks,
and drive tests. For the full API and source code, visit
the
GitHub repository.