firsthand

Rendering

Index · Previous: Components · Next: Context and lifecycle


Text and attributes

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'} />

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.

Conditionals

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>;

Lists

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:

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.

Lists of plain values

{
  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.

Fragments

<>
  <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.

Portals

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.

Refs

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();
    });
  }}
/>

Raw nodes

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.

What a child position accepts

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.