Ajo

Lifecycle

this.signal, cleanup, async work, error boundaries and ending a component.

A stateful component’s lifecycle follows the order of its code. Setup runs before the loop, each render is one pass through the loop, and cleanup lives in a finally block or behind this.signal. There are no lifecycle methods to register. This page covers how a component ends, how to clean up after it, how to load data and how errors travel up the tree.

From first render to the end

MomentWhat runs
First renderThe code before the loop, then the loop up to the first yield.
Every later renderThe generator resumes after its last yield and runs to the next one.
EndThe finally blocks run, then this.signal aborts. If the host was removed, refs then receive null.

A component ends when one of these happens:

  • A render removes it from the tree, directly or together with an ancestor.
  • Other code removes its host from the document, as described in Removal outside Ajo.
  • Something calls return() on it.
  • Its generator finishes.

Nested components end from the inside out: the deepest descendants first, the component itself last.

this.signal

Every stateful component has this.signal, an AbortSignal that aborts when the component ends. Pass it to any API that accepts a signal, and that API cleans up after itself.

src/quick-note.tsx
import type { Stateful } from 'ajo'

export const QuickNote: Stateful = function* () {

  let open = false

  document.addEventListener('keydown', e => {
    if (e.altKey && e.code == 'KeyN') this.next(() => open = !open)
  }, { signal: this.signal }) // removed when the component ends

  while (true) yield open
    ? <textarea placeholder="A quick note" />
    : <p>Press Alt+N to write a quick note.</p>
}

For an API without a signal option, register an abort listener that undoes the setup:

TSX
const id = setInterval(() => this.next(), 60_000)
this.signal.addEventListener('abort', () => clearInterval(id))

const observer = new ResizeObserver(() => this.next())
observer.observe(this)
this.signal.addEventListener('abort', () => observer.disconnect())

Each run of the generator gets its own signal. After a reset, the setup code runs again with a fresh one, so listeners registered this way are removed and added back without extra code.

Cleanup with try and finally

A generator’s finally block runs when the generator ends, which makes it the other natural place for cleanup. Wrap the loop in try:

src/clock.tsx
import type { Stateful } from 'ajo'

export const Clock: Stateful = function* () {

  let now = new Date()

  const id = setInterval(() => this.next(() => now = new Date()), 1000)

  try {
    while (true) yield <time>{now.toLocaleTimeString()}</time>
  } finally {
    clearInterval(id)
  }
}

The finally block runs before the signal aborts, so inside it this.signal.aborted is still false.

Async work

Start async work in the setup code, pass it the signal, and apply the result with this.next(). The loop renders whichever state the component is in.

src/note-view.tsx
import type { Stateful } from 'ajo'

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

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

  let note = null as Note | null
  let error = null as Error | null

  fetch(`/api/notes/${id}`, { signal: this.signal })
    .then(res => res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`)))
    .then((data: Note) => this.next(() => note = data))
    .catch(e => e.name == 'AbortError' || this.next(() => error = e))

  while (true) {
    if (error) yield <p role="alert">Could not load the note: {error.message}</p>
    else if (!note) yield <p>Loading…</p>
    else yield <article><h1>{note.title}</h1><p>{note.body}</p></article>
  }
}

If the component ends while the request is in flight, the signal aborts it and the promise rejects with an AbortError. That is expected rather than a failure, and there is nothing left to show, so the catch filters it out. Any other error is rendered.

This component loads once, for the id of its first render. If the id can change while it stays mounted, render it with key={id}, so a new id means a new component, or move the loading into a keyed loader that runs inside the loop, as in Cloves.

Error boundaries

An error thrown during a render, by a stateless component or by a descendant’s generator, travels up to the nearest stateful component and is thrown into its generator at the yield where it is paused. An error thrown by a this.next() callback starts at the component that called it. Put try and catch inside the loop, and the component becomes a boundary:

src/boundary.tsx
import type { Stateful, WithChildren } from 'ajo'

export const Boundary: Stateful<WithChildren> = function* () {

  for (const { children } of this) {
    try {
      yield children
    } catch (error) {
      yield (
        <div role="alert">
          <p>{error instanceof Error ? error.message : String(error)}</p>
          <button set:onclick={() => this.next()}>Try again</button>
        </div>
      )
    }
  }
}

The catch block yields a fallback in place of the children. The next render continues the loop, and the try block renders the children again, so the retry button needs nothing more than this.next().

When an error passes through a stateful component that does not catch it, Ajo hands it to the next ancestor as a new Error with the same message, and the error it replaces as its cause. Check error.cause if you look for a specific error class. When no component catches the error, it is thrown from the call that started the render: render(), this.next() or the event handler that called it. An error thrown by a finally block while its component is removed also goes to the nearest boundary above it; the component still ends and its signal still aborts.

Routing errors with this.throw()

Errors in async code happen outside any render, where no boundary can see them. Route one explicitly with this.throw(error): Ajo throws it into the component itself first, then into its ancestors, until one catches it.

TSX
const upload = async () => {
  try {
    await sync(notes)
    this.next(() => synced = true)
  } catch (error) {
    this.throw(error) // the nearest boundary renders its fallback
  }
}

Ending and resetting

this.return() ends the component’s generator: its finally blocks run and its signal aborts. By default it ends every stateful component inside it as well; this.return(false) ends only this one. The host element and its DOM stay where they are.

A component that has ended starts over on its next render, whether that comes from this.next() or from its parent: from the top of the generator, with fresh state and a fresh signal. A reset is therefore two calls.

src/compose.tsx
import type { Stateful } from 'ajo'

export const Compose: Stateful = function* () {

  let title = ''

  // End this run, including every component inside, then start again from the top.
  const discard = () => { this.return(); this.next() }

  while (true) yield (
    <form set:onsubmit={(e: SubmitEvent) => e.preventDefault()}>
      <input set:value={title} set:oninput={e => title = (e.target as HTMLInputElement).value} />
      <button type="button" set:onclick={discard}>Discard</button>
    </form>
  )
}

The same works from outside through a ref: el.return() followed by el.next(). When a generator finishes on its own, the value it returns is rendered, the component ends as if return() had been called, and its next render starts it again.

The rules of this.next()

  • It returns what the callback returned, or undefined without a callback.
  • It does nothing while the host is not connected to the document, or while the component is ending. The callback is not called either, so callbacks that arrive late are harmless.
  • It is not re-entrant. Called while the same component, or one of its ancestors, is rendering, it runs the callback but does not start a nested render.
  • In setup code, before the first yield, assign state directly. On a fresh mount the host is not in the document yet, so next() would skip its callback.
  • An abort listener that calls it while its component is being removed does not restart the component.
  • If the callback throws, the error is routed as with this.throw().

Removal outside Ajo

Other code can remove a host too, with element.remove(), a library that replaces some content, or innerHTML on a parent. Ajo notices. Once the first stateful component mounts, Ajo observes the document with a MutationObserver. When a removed element is no longer connected, Ajo ends the components inside it, forgets their keys and calls their refs with null.

This cleanup is asynchronous: it runs in a microtask after the removal, not during it. A node that was moved rather than removed is connected again by then, and is left alone.

Source of truth: README.md in cristianfalcone/ajo