Ajo

Cloves

Reusable stateful logic as plain functions of the component host.

Other frameworks need a dedicated mechanism to share component logic. React has hooks, with ordering rules because state lives in positional slots; Vue has composables, wired into a reactivity graph. Ajo needs neither. A generator body runs once, so its closures are real, and the component already exposes everything shared logic needs: this.signal for cleanup and this.next() for invalidation.

A clove is that idea as a convention. Ajo means garlic, and a bulb is made of cloves: a clove is a plain function that takes the component’s host and returns a live view of one concern. There is no runtime behind it, no registry and no ordering rule.

A first clove

src/cloves/pointer.ts
import type { Host } from 'ajo'

/** Tracks the pointer position while the component is mounted. */
export const pointer = (host: Host) => {

  const pos = { x: 0, y: 0 }

  if (typeof document != 'undefined' && host.nodeType == 1) // inert during server rendering
    document.addEventListener('pointermove',
      e => host.next(() => { pos.x = e.clientX; pos.y = e.clientY }),
      { signal: host.signal }) // removed on unmount and on reset

  return pos
}
TSX
const Cursor: Stateful = function* () {
  const pos = pointer(this) // setup: call it before the loop
  while (true) yield <p>{pos.x}, {pos.y}</p>
}

The component calls the clove once, in its setup code, and keeps the object it returns. The clove mutates that object and asks the host to render again; the component reads it in the loop like any other state.

The six rules

These rules are what make a clove reliable. All six apply to every clove.

  1. The signature is (host, options?) => view. The host, this, always comes first. Options go in a single object with defaults, never in positional flags.
  2. Cleanup goes through host.signal, and only through it. Pass { signal: host.signal } to addEventListener; for timers, observers and sockets, register an abort listener. Never return a dispose function. Teardown then composes with unmount and with a reset: el.return() followed by el.next() restarts the generator with a fresh signal, setup runs again and the clove subscribes again with no extra code.
  3. Invalidation goes through host.next(fn), with the state mutated inside fn. That is safe by design: it does nothing after unmount and never renders re-entrantly.
  4. The view is a stable reference. Mutate its fields; never reassign or spread it, which breaks the live connection, the same contract Ajo’s own args object follows. Use getters for derived values. Name state as nouns, such as x, open or data, and methods as verbs, such as load or start.
  5. Per-render input is an explicit call inside the render loop, which does nothing when the input has not changed. There is no hidden dependency tracking; in Ajo, lifecycle is code position.
  6. Cloves are inert on the server, by shape. During server rendering the host only implements the protocol: there is no DOM, and next does nothing. Guard DOM access with typeof document != 'undefined' && host.nodeType == 1 and always return a view of the right shape, so universal components can call cloves unconditionally.

Name cloves after what they provide, as pointer or loader. A use prefix would suggest React’s rules, and none of them apply here.

The Host type

TypeScript users write cloves against Host from ajo: the host protocol together with its DOM element. Any stateful component’s this satisfies it. Pass an element type to narrow it when a clove needs a particular element.

TypeScript
import type { Host } from 'ajo'

const focus = (host: Host) => { /* host.signal, host.next(), host.addEventListener()… */ }
const input = (host: Host<HTMLInputElement>) => host.value

Per-render input: a keyed loader

A clove that depends on args exposes a method, and the component calls it on every render, inside the loop. The method remembers its last input and does nothing when it has not changed.

src/cloves/loader.ts
import type { Host } from 'ajo'

/** Loads JSON for a URL, again only when the URL changes. */
export const loader = <T,>(host: Host) => {

  let key: string | undefined

  const q = {
    data: null as T | null,
    error: null as Error | null,
    loading: false,
    load(url: string) {
      if (url == key) return // unchanged input: nothing to do
      key = url
      q.loading = true
      q.data = q.error = null
      if (typeof document == 'undefined' || host.nodeType != 1) return // server: stay in the loading shape
      fetch(url, { signal: host.signal })
        .then(res => res.json())
        .then(data => url == key && host.next(() => { q.data = data; q.loading = false }))
        .catch(e => e.name == 'AbortError' || url != key || host.next(() => { q.error = e; q.loading = false }))
    },
  }

  return q
}
src/note-page.tsx
import type { Stateful } from 'ajo'
import { loader } from './cloves/loader'

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

export const NotePage: Stateful<{ id: string }> = function* () {

  const note = loader<Note>(this)

  for (const { id } of this) {
    note.load(`/api/notes/${id}`) // runs every render, loads only when id changes
    yield note.loading ? <p>Loading…</p>
      : note.error ? <p role="alert">{note.error.message}</p>
      : <article><h1>{note.data!.title}</h1><p>{note.data!.body}</p></article>
  }
}

The signal ends the request when the component ends. The comparison with key drops a response that arrives after the URL has already changed, so a slow earlier request cannot overwrite a newer one. On the server the view stays in its loading shape and no request is made.

Composition

Cloves compose by calling each other with the same host. Here saved builds a “saved 3 minutes ago” label on top of a clock:

src/cloves/saved.ts
import type { Host } from 'ajo'

/** The current time, refreshed on an interval while the component is mounted. */
export const clock = (host: Host, { every = 1000 } = {}) => {

  const view = { now: Date.now() }

  if (typeof document != 'undefined' && host.nodeType == 1) {
    const id = setInterval(() => host.next(() => { view.now = Date.now() }), every)
    host.signal.addEventListener('abort', () => clearInterval(id))
  }

  return view
}

/** When a draft was last saved, as a label that stays current. */
export const saved = (host: Host) => {

  const time = clock(host, { every: 30_000 })

  const view = {
    at: null as number | null,
    mark() { host.next(() => { view.at = Date.now() }) },
    get label() {
      if (view.at == null) return 'Not saved yet'
      const minutes = Math.floor((time.now - view.at) / 60_000)
      return minutes < 1 ? 'Saved just now' : `Saved ${minutes} min ago`
    },
  }

  return view
}

Because cloves are ordinary function calls, conditional calls are fine. Return a value of the same shape on the other branch so the loop does not need to care:

TSX
const Editor: Stateful<{ autosave?: boolean }> = function* ({ autosave }) {

  const status = autosave ? saved(this) : { label: '' }

  while (true) yield <p class="status">{status.label}</p>
}

Shared state across components

State shared by several components is a clove over module-level state. The module keeps a Set of hosts, mutations call next() on each of them, and each host leaves the set through its own signal.

src/cloves/pins.ts
import type { Host } from 'ajo'

const pinned = new Set<string>()
const hosts = new Set<Host>()

/** Pinned notes, shared by every component that calls pins(this). */
export const pins = (host: Host) => {

  if (typeof document != 'undefined' && host.nodeType == 1) {
    hosts.add(host)
    host.signal.addEventListener('abort', () => hosts.delete(host))
  }

  return {
    get count() { return pinned.size },
    has: (id: string) => pinned.has(id),
    toggle(id: string) {
      if (!pinned.delete(id)) pinned.add(id)
      for (const each of hosts) each.next()
    },
  }
}
TSX
const PinButton: Stateful<{ id: string }> = function* () {
  const p = pins(this)
  for (const { id } of this) yield (
    <button aria-pressed={p.has(id) ? 'true' : 'false'} set:onclick={() => p.toggle(id)}>Pin</button>
  )
}

const PinCount: Stateful = function* () {
  const p = pins(this)
  while (true) yield <span>{p.count} pinned</span>
}

Anti-patterns

TypeScript
// A dispose function: cleanup belongs to host.signal.
const bad1 = (host: Host) => { /* … */ return () => cleanup() }

// A copy: the component keeps a snapshot that never changes.
const bad2 = (host: Host) => ({ ...state })

// A use prefix suggests React’s rules, which do not exist here.
const useBad3 = (host: Host) => { }

// A leak: nothing stops the interval when the component ends.
setInterval(() => host.next(), 1000)

// No host.signal and no AbortError filter.
fetch(url).catch(e => host.next(() => error = e))

Ready-made cloves

The ajo-cloves package collects ready-made cloves for interaction, focus and sensing. Applications and component libraries import only the behaviors they need. The Cloves catalog lists them.

Source of truth: LLMs.md in cristianfalcone/ajo (Cloves)