Ajo
Components
Stateless functions, stateful generators, this.next() and fresh arguments.
Ajo has two kinds of components. A stateless component is a function that turns its args into something to render. A stateful component is a generator function: it keeps its state in ordinary local variables and yields a new tree every time it renders. Both are used from JSX in the same way, and both receive everything written on them as one object, their args.
Stateless components
A stateless component receives its args and returns anything Ajo can render: JSX, text, numbers, arrays or null. Ajo calls it every time the tree around it renders. It has no instance and no element of its own; its result takes its place in the tree.
import type { Stateless, WithChildren } from 'ajo'
type EntryArgs = WithChildren<{ title: string; date: string; pinned?: boolean }>
export const Entry: Stateless<EntryArgs> = ({ title, date, pinned, children }) => (
<article class={pinned ? 'entry pinned' : 'entry'}>
<h2>{title}</h2>
<time datetime={date}>{date}</time>
{children}
</article>
)<Entry title="Morning pages" date="2026-09-21" pinned>
<p>Wrote before coffee. Better than expected.</p>
</Entry>Everything written on a stateless component arrives in its args, including key, memo and ref. Those names only have a special meaning on elements and on stateful components, so a stateless component that should honor one applies it to an element it returns:
type RowArgs = { key: string; title: string; deps?: unknown }
// key and memo arrive as ordinary args: apply them to the returned element.
const Row: Stateless<RowArgs> = ({ key, title, deps }) => <li key={key} memo={deps}>{title}</li>
<ul>
{entries.map(entry => <Row key={entry.id} title={entry.title} deps={[entry.updated]} />)}
</ul>When a component returns something other than the list item, key the element around it instead: <li key={entry.id}><Entry … /></li>.
Stateful components
A stateful component is a generator function. Ajo creates a host element for it, a <div> by default, and renders whatever the generator yields inside that element. Inside the generator, this is the host: a real DOM element with a few extra members.
import type { Stateful } from 'ajo'
export const Draft: Stateful = function* () {
// Before the loop: runs once, when the component first renders.
let text = ''
let saves = 0
const edit = (e: Event) => this.next(() => text = (e.target as HTMLTextAreaElement).value)
const save = () => this.next(() => saves++)
// The loop: one pass per render.
while (true) {
const words = text.trim() ? text.trim().split(/\s+/).length : 0
yield (
<>
<textarea set:value={text} set:oninput={edit} />
<p>{words} words, saved {saves} times</p>
<button set:onclick={save} disabled={!text}>Save</button>
</>
)
}
}The shape of the function is the model. State and handlers live before the loop, in closures that last as long as the component. Values derived from state are computed inside the loop, fresh on every render. There is no dependency list and no special storage: text is a variable.
How a render moves the generator
Each render resumes the generator and runs it to its next yield; the yielded value becomes the host’s content. On the first render that includes everything before the loop. Code placed after a yield runs at the start of the following render, not right after this one.
Because the component is an ordinary generator, yield may also appear outside a loop, for example in a first phase before it. Every render advances the generator by one yield, whether it was requested with this.next() or caused by the parent rendering again with new args.
Rendering again with this.next()
Assigning a variable does not update the page. Call this.next(fn): Ajo runs fn, renders the component again, synchronously, and returns what fn returned. The callback receives the current args, and this.next() without a callback renders again with no change.
const add = () => this.next(({ step = 1 }) => count += step) // returns the new count
const later = () => this.next(async () => {
saving = true // applied before this render
await save(text)
this.next(() => saving = false) // after an await, render again explicitly
})An async callback returns its promise. Only the part before its first await is applied to the render that next() performs; changes made after an await need another this.next(). After the component has been removed, next() does nothing, so late callbacks are safe; Lifecycle lists the exact rules.
Args in the render loop
Ajo keeps one args object per stateful component and updates its fields before every render. There are three ways to read it, and each suits a different need.
The parameter, destructured, gives the values of the first render: use it for setup code. A for…of this loop yields the args on every render; destructuring in the loop head gives fresh bindings each time.
type GoalArgs = { start: number; step?: number }
const Goal: Stateful<GoalArgs> = function* ({ start }) {
let words = start // the parameter: read once, for setup
for (const { step = 100 } of this) { // fresh args on every render
yield <button set:onclick={() => this.next(() => words += step)}>Goal: {words} words (+{step})</button>
}
}Without const, for ({ title } of this) assigns to variables declared in the parameter instead. Handlers created once, before the loop, then read the current value.
const Share: Stateful<{ title: string }> = function* ({ title }) {
// Created once, but reads the outer title, which the loop keeps current.
const copy = () => navigator.clipboard.writeText(title)
for ({ title } of this) yield <button set:onclick={copy}>Copy “{title}”</button>
}When a component needs no args in its loop, while (true) is enough. The args object itself is live: a generator that keeps the parameter whole, as function* (args), always finds the current values in args.title. Only destructuring copies the values of a moment.
The host element
Three properties on the generator function configure its host. is picks the tag, attrs sets default host attributes and args sets default args. The stateful() helper sets is and infers the type of this in one step.
import type { Stateful } from 'ajo'
import { stateful } from 'ajo'
type Args = { title: string; limit?: number }
export const Notebook: Stateful<Args, 'section'> = function* () {
for (const { title, limit = 20 } of this) yield <h2>{title} (latest {limit})</h2>
}
Notebook.is = 'section' // host tag, default div
Notebook.attrs = { class: 'notebook' } // default host attributes
Notebook.args = { limit: 20 } // default args
// The same host, without repeating 'section':
export const Shelf = stateful(function* ({ title }: Args) {
while (true) yield <h2>{title}</h2>
}, 'section')<Notebook title="Travel" /> renders <section class="notebook"><h2>Travel (latest 20)</h2></section>. Without is, the host uses defaults.tag, which is div; the API reference shows how to change it.
What goes to the host
Written on a stateful component, a few names configure the host. Everything else, children included, becomes an arg.
| Written as | Goes to | Effect |
|---|---|---|
key | Host | Identifies the component among its siblings. |
memo | Host | Skips updates from the parent while its value is unchanged. |
skip | Host | Ajo leaves the host’s children alone; the component does not render. |
ref | Host | Receives the host element, or null when it is removed. |
set:name | Host | Assigns a DOM property on the host. |
attr:name | Host | Sets an HTML attribute on the host. |
| Anything else | Args | Available through the parameter, for…of this and this.next(fn). |
<Notebook
title="Travel" // arg
attr:id="travel" // host attribute
set:onkeydown={onKeydown} // host property
key={notebook.id} // host key
/>An attr: value replaces the default from attrs of the same name rather than merging with it, so attr:class overrides class: 'notebook'.
Refs to stateful components
A ref on a stateful component receives the host with its extra members: next(), throw() and return(). That lets code outside the component ask it to render again.
import type { Stateful } from 'ajo'
import { render } from 'ajo'
const notes: string[] = []
const NoteCount: Stateful = function* () {
while (true) yield <p>{notes.length} notes</p>
}
let counter: ThisParameterType<typeof NoteCount> | null = null
render(<NoteCount ref={el => counter = el} />, document.getElementById('count')!)
export function addNote(text: string) {
notes.push(text)
counter?.next()
}Fragments inside the host
A stateful component already has an element of its own. Yield a fragment, <>…</>, to place several children directly in the host. Yielding a <div> instead nests a second element inside the first; to style the host itself, give it attrs or a different tag.
// <div><h2>Today</h2><ul>…</ul></div>
while (true) yield (
<>
<h2>Today</h2>
<ul>{items}</ul>
</>
)TypeScript
| Type | Use it for |
|---|---|
Stateless<Args> | A function component. Args default to an empty object. |
Stateful<Args, Tag> | A generator component. Tag types the host and this; it defaults to div. |
WithChildren<Args> | Adds an optional children arg. |
ThisParameterType<typeof C> | The host type of a stateful component, for refs. |
Host | Any stateful host; see Cloves. |
import type { Stateful, Stateless, WithChildren } from 'ajo'
type CardArgs = WithChildren<{ title: string }>
const Card: Stateless<CardArgs> = ({ title, children }) => (
<section class="card"><h3>{title}</h3>{children}</section>
)
type TimerArgs = { minutes: number }
const Timer: Stateful<TimerArgs, 'time'> = function* () {
for (const { minutes } of this) yield <>{minutes} min</>
}
Timer.is = 'time' // required: the type names a host other than div
let timer: ThisParameterType<typeof Timer> | null = null // HTMLTimeElement plus next(), throw(), return()A few details make the types work smoothly:
- Declare args with a
typealias, not aninterface. Args must be assignable toRecord<string, unknown>, which interfaces are not. - When the type names a host tag other than
div,isbecomes a required property. Assign it after the function, or usestateful(fn, tag), which sets it for you. - Annotate a generator with
Statefulor wrap it withstateful()sothisis typed. - TypeScript narrows
let note: Note | null = nulltonulland does not see assignments made later in callbacks. Writelet note = null as Note | nullfor state that callbacks fill in.
- RenderingAttributes, properties, keys, memo, skip and refs.
- LifecycleCleanup, async work, error boundaries and resets.
- ClovesShare stateful logic between components.
- API referenceEvery export, host member and type.
Source of truth: README.md in cristianfalcone/ajo