Ajo

Rendering

Attributes and properties, events, keys, memo, skip, refs and SVG.

Ajo’s JSX is written close to HTML. Names on an element become HTML attributes, a set: prefix assigns a DOM property instead, and a handful of special attributes control how Ajo updates the element. This page covers each of them, and how Ajo decides which DOM nodes to keep when a tree renders again.

Attributes and properties

Ajo is HTML-first: apart from the special attributes described below, every name you write on an element becomes an HTML attribute. Prefix a name with set: to assign the DOM property of that name instead, as in element.value = text. The difference matters for values the browser keeps apart from the markup.

TSX
<input value="Untitled" />            // attribute: the initial value only
<input set:value={title} />           // property: follows your state on every render

<input type="checkbox" checked />     // attribute: initially checked
<input type="checkbox" set:checked={done} />

<video set:currentTime={0} set:muted />

Use a property whenever the page should reflect state after the user has interacted with it. Once someone types into an input, its value attribute no longer describes what it shows; set:value does.

Events

Event handlers are DOM properties too, so they always use set:, as in set:onclick, set:oninput and set:onsubmit. Ajo assigns the function to the element’s property, which holds one handler per event. For several listeners, or listener options, call addEventListener with { signal: this.signal } in a stateful component; Lifecycle shows how.

TSX
<form set:onsubmit={(e: SubmitEvent) => { e.preventDefault(); save() }}>
  <textarea set:value={text} set:oninput={e => text = (e.target as HTMLTextAreaElement).value} />
  <button>Save</button>
</form>

When a name disappears from the JSX between two renders, Ajo removes the attribute, or assigns undefined to the property for a set: name.

Class and style

class and style are plain strings. Ajo has no object or array syntax for either; TypeScript reports one as an error. Build the string with a template literal, or with a helper such as the clsx package if you already like it. Ajo does not need one.

TSX
<li class={`entry ${pinned ? 'pinned' : ''}`}>…</li>
<li class={clsx('entry', { pinned })}>…</li>          // clsx is optional

<span style="color: var(--muted)">draft</span>
<span style={`width: ${progress}%`} />

Boolean attributes

true writes an attribute with an empty value. false, null and undefined remove it. Other values are converted to strings by the browser.

TSX
<input type="checkbox" checked disabled />   // checked="" disabled=""
<button disabled={false}>Save</button>          // no disabled attribute
<button aria-pressed={pinned ? 'true' : 'false'}>Pin</button>

How Ajo updates the DOM

Ajo reconciles in place. When a container renders, Ajo walks its existing child nodes in order, alongside the new children, and looks for a node it can reuse for each one:

  • Text reuses the next text node and changes its content only if it differs.
  • An element reuses the next element with the same tag name. An element with a key is first looked up by that key among the container’s children, wherever it currently is.
  • A node that was rendered with a key is only reused for the same key. A node without one, such as an element that came from server HTML, can be taken over by any element with the same tag.
  • A stateful component’s host is only reused for the same component. Swapping one component for another at the same position creates a new host, even when both use a div, so state never passes between them.
  • When nothing matches, Ajo creates a node.

Reused and new nodes are moved into place. Whatever is left over at the end is removed, and the components inside it are ended: their finally blocks run, their signals abort and their refs receive null.

On each element, Ajo compares the attributes with the previous render of that element and writes only the ones that changed. The first time it takes over an existing element, for example one rendered on the server, it also removes attributes the JSX doesn’t mention, so stale markup does not survive hydration.

Focus while reordering

Moving a DOM node that contains the focused element makes it lose focus. When a reorder would move such a node, Ajo moves the nodes around it instead, so an input keeps focus while the list it lives in is sorted or filtered.

Lists and keys

Give each item in a list a key that is unique among its siblings: a string or a number, usually a record id. Keys let Ajo find the same node again when items are added, removed or reordered.

TSX
import type { Stateless } from 'ajo'

type Note = { id: string; title: string }

const Notes: Stateless<{ notes: Note[] }> = ({ notes }) => (
  <ul>
    {notes.map(note => <li key={note.id}>{note.title}</li>)}
  </ul>
)

Without keys, Ajo matches items by position. For plain rows that costs extra DOM work. For rows that are stateful components, or that hold focus or typed text, state stays with the position: when an item is removed, the item after it takes over its row, state included. On a stateful component, key applies to its host: <NoteEditor key={note.id} note={note} />.

Changing a key tells Ajo that an item is a different one. The old node is removed and a new one is created, which is also a direct way to give a component fresh state.

memo

memo lets an element skip work while its dependencies stay the same. While they are unchanged, Ajo leaves the element alone entirely: its attributes, its children and the components inside it are not updated.

FormUpdates when
memo={[a, b]}Any item changes, compared one by one with ===, or the length changes.
memo={value}The value changes, compared with ===.
memoNever. The element renders once.
TSX
<article memo={[note.id, note.updated]}>
  <h2>{note.title}</h2>
  <p>{note.excerpt}</p>
</article>

<footer memo>Written with Ajo</footer>

An element always renders in full the first time Ajo sees it, including when it takes over server-rendered HTML. On a stateful component, memo applies to the host: renders of the parent skip the component while the value is unchanged, but the component’s own this.next() still renders it.

skip

skip keeps Ajo out of an element’s children. The element’s own attributes and set: properties still update, but its content belongs to someone else, typically a library that manages its own DOM.

src/words-chart.tsx
import type { Stateful } from 'ajo'
import { createChart, type Chart } from './chart' // your wrapper around a charting library

export const WordsChart: Stateful<{ days: number[] }> = function* () {

  let chart = null as Chart | null

  this.signal.addEventListener('abort', () => chart?.destroy())

  for (const { days } of this) {
    chart?.update(days)
    yield <div class="chart" skip ref={el => { if (el && !chart) chart = createChart(el, days) }} />
  }
}

Properties that fill an element from a string, set:innerHTML and set:textContent, need skip as well. Without it, Ajo reconciles the element’s children against the JSX, which has none, and clears what the property inserted.

TSX
<div class="entry-body" set:innerHTML={trustedHtml} skip />

ref

ref takes a callback that receives the element. Ajo calls it when the element is first rendered, and again on later renders when you pass a different function; an inline arrow function is a new function each time, so it runs on every render. When the element is removed, the last callback receives null.

TSX
const Search: Stateful = function* () {

  let input: HTMLInputElement | null = null

  while (true) yield (
    <>
      <input type="search" ref={el => input = el} />
      <button set:onclick={() => input?.focus()}>Find</button>
    </>
  )
}

The callback runs while Ajo updates the element, before its children render. For work that needs the finished DOM, schedule it after the render, as shown below. A ref on a stateful component receives its host, with next() and the other host members; see Components.

SVG

Ajo creates each element in its parent’s namespace unless the element has an xmlns. Set it on the root <svg>, and every element inside inherits the SVG namespace. Without it, the browser creates an unknown HTML element that draws nothing.

TSX
const Leaf = () => (
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
    <path d="M5 21c8 0 14-6 14-16C9 5 5 11 5 21Z" fill="none" stroke="currentColor" />
  </svg>
)

Work after a render

Ajo renders synchronously: when a call to render() returns, the DOM is up to date, and so it is after this.next() called from an event handler. Inside a component, queue a microtask before the yield; it runs once the current render has finished.

TSX
type Line = { id: string; text: string }

const Log: Stateful<{ lines: Line[] }> = function* () {

  let list: HTMLOListElement | null = null

  for (const { lines } of this) {
    queueMicrotask(() => list?.lastElementChild?.scrollIntoView({ block: 'nearest' }))
    yield (
      <ol ref={el => list = el}>
        {lines.map(line => <li key={line.id}>{line.text}</li>)}
      </ol>
    )
  }
}

Special attributes at a glance

AttributeOn an elementOn a stateful component
keyIdentifies it among its siblings.Identifies the host.
memoSkips updates while unchanged.Skips updates from the parent while unchanged.
skipLeaves its children alone.Leaves the host’s children alone; the component does not render.
refReceives the element, then null.Receives the host, then null.
set:nameAssigns a DOM property.Assigns a DOM property on the host.
attr:nameNot special on elements.Sets an HTML attribute on the host.

On a stateless component, all of these are ordinary args.

Source of truth: README.md in cristianfalcone/ajo