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
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
}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.
- The signature is
(host, options?) => view. The host,this, always comes first. Options go in a single object with defaults, never in positional flags. - Cleanup goes through
host.signal, and only through it. Pass{ signal: host.signal }toaddEventListener; for timers, observers and sockets, register anabortlistener. Never return a dispose function. Teardown then composes with unmount and with a reset:el.return()followed byel.next()restarts the generator with a fresh signal, setup runs again and the clove subscribes again with no extra code. - Invalidation goes through
host.next(fn), with the state mutated insidefn. That is safe by design: it does nothing after unmount and never renders re-entrantly. - 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,openordata, and methods as verbs, such asloadorstart. - 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.
- Cloves are inert on the server, by shape. During server rendering the host only implements the protocol: there is no DOM, and
nextdoes nothing. Guard DOM access withtypeof document != 'undefined' && host.nodeType == 1and 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.
import type { Host } from 'ajo'
const focus = (host: Host) => { /* host.signal, host.next(), host.addEventListener()… */ }
const input = (host: Host<HTMLInputElement>) => host.valuePer-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.
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
}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:
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:
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.
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()
},
}
}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
// 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.
- Cloves catalogReady-made behaviors from the ajo-cloves package.
- LifecycleSignals, cleanup and resets, which cloves build on.
- Server renderingWhy cloves must stay inert on the server.
Source of truth: LLMs.md in cristianfalcone/ajo (Cloves)