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.

src/entry.tsx
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>
)
TSX
<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:

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

src/draft.tsx
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.

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

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

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

src/notebook.tsx
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 asGoes toEffect
keyHostIdentifies the component among its siblings.
memoHostSkips updates from the parent while its value is unchanged.
skipHostAjo leaves the host’s children alone; the component does not render.
refHostReceives the host element, or null when it is removed.
set:nameHostAssigns a DOM property on the host.
attr:nameHostSets an HTML attribute on the host.
Anything elseArgsAvailable through the parameter, for…of this and this.next(fn).
TSX
<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.

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

TSX
// <div><h2>Today</h2><ul>…</ul></div>
while (true) yield (
  <>
    <h2>Today</h2>
    <ul>{items}</ul>
  </>
)

TypeScript

TypeUse 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.
HostAny stateful host; see Cloves.
TSX
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 type alias, not an interface. Args must be assignable to Record<string, unknown>, which interfaces are not.
  • When the type names a host tag other than div, is becomes a required property. Assign it after the function, or use stateful(fn, tag), which sets it for you.
  • Annotate a generator with Stateful or wrap it with stateful() so this is typed.
  • TypeScript narrows let note: Note | null = null to null and does not see assignments made later in callbacks. Write let note = null as Note | null for state that callbacks fill in.

Source of truth: README.md in cristianfalcone/ajo