Index · Previous: Components · Next: Context and lifecycle
Any expression in a child position becomes a text node that updates on its own:
<p>
{count.value} items, {user.value.name} is logged in
</p>
Attributes are the same, with the right kind of write chosen at compile time:
<input value={text.value} disabled={busy.value} class={active.value ? 'on' : 'off'} />
value and disabled are DOM properties, because that is what they are.class accepts a string or a record: class=
toggles only what changed.style accepts a string or a record, and a record may hold custom
properties: style=--accent.data-* / aria-* always do.Escape hatches when the guess is wrong:
<div prop:innerHTML={html.value} /> {/* a property, explicitly */}
<div attr:value={text.value} /> {/* an attribute, explicitly */}
Values are written as text, never parsed as HTML — {userInput} cannot
introduce markup. prop:innerHTML is the documented exception, and it is your
responsibility.
Ordinary JavaScript:
<div>
{error.value !== null ? <Alert message={error.value} /> : null}
{items.value.length === 0 && <Empty />}
</div>
The branch is re-evaluated when what it read changes, and only then. A component in the branch that goes away is disposed: its effects unsubscribe and its cleanups run.
For more than two branches, a lookup reads better than nested ternaries:
const VIEWS = { list: List, grid: Grid, table: Table };
<div>{VIEWS[mode.value]({})}</div>;
A .map() with a key compiles into a keyed list part:
<ul>
{todos.value.map((todo) => (
<TodoRow key={todo.id} todo={todo} />
))}
</ul>
What the key buys you:
insertBefore calls are issued;Without a key, the array is reconciled by node identity, which for freshly created nodes means “replace”. That is the documented cost of leaving it out.
Duplicate keys warn and drop the duplicate rather than corrupting the DOM.
The reconciliation algorithm was chosen by measuring three candidates over ten operations at two sizes — see ADR-0010.
{
tags.value.map((name) => <span key={name}>{name}</span>);
}
The key must be stable and unique. An index works only if the list is never reordered or spliced.
<>
<dt>{term.value}</dt>
<dd>{definition.value}</dd>
</>
A fragment produces its children with no wrapper element. Dynamic children of a fragment are anchored where they were written and bound under the scope that wrote them, so context and disposal stay lexical.
Render somewhere else in the document while staying in the component tree:
import { portal } from '@firsthandjs/dom';
const Modal = component<{ children?: View }>((props) => {
const theme = useContext(ThemeContext); // still the theme from *here*
return portal(() => <div class={`modal ${theme.value}`}>{props.children}</div>, document.body);
});
Context, disposal and error boundaries follow the component tree, not the DOM tree — that is what the owner tree is for (ADR-0008). Closing the component removes the portalled nodes.
let input!: HTMLInputElement;
<input ref={(element) => (input = element)} />;
The callback runs once, with the element, as it is created. For something that needs the element to be connected, do it in an effect:
<input
ref={(element) => {
effect(() => {
if (focused.value) element.focus();
});
}}
/>
Anything that is already a Node can be rendered:
const chart = document.createElement('canvas');
drawInto(chart);
<figure>{chart}</figure>;
This is how you embed a library that owns its own DOM — a chart, a map, an
editor. Dispose it with onCleanup.
string, number, boolean, null, undefined, a Node, an array of any
of those, or a function returning one. null, undefined and booleans render
nothing, which is what makes {condition && <Thing />} work.
Next: Context and lifecycle — sharing values, cleaning up, and catching errors.