A modern JavaScript library for building user interfaces with generator-based state management and efficient DOM updates.
- Generator-Based Components: Use
function*for stateful components with built-in lifecycle - Efficient DOM Updates: In-place reconciliation minimizes DOM manipulation
npm install ajoimport { render } from 'ajo'
function* Counter() {
let count = 0
while (true) yield (
<button set:onclick={() => this.next(() => count++)}>
Count: {count}
</button>
)
}
render(<Counter />, document.body)Configure your build tool to use Ajo's automatic JSX runtime:
Vite:
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
esbuild: {
jsx: 'automatic',
jsxImportSource: 'ajo',
},
})TypeScript:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "ajo"
}
}Other build tools: Set jsx: 'react-jsx' (or 'automatic'), jsxImportSource: 'ajo'. No manual imports needed — the build tool auto-imports from ajo/jsx-runtime.
Pure functions that receive props and return JSX:
const Greeting = ({ name }) => <p>Hello, {name}!</p>Generator functions with automatic wrapper elements. The structure provides a natural mental model:
- Before the loop: Persistent state and handlers (survives re-renders)
- Inside the loop: Derived values (computed fresh each render)
function* TodoList() {
let todos = []
let text = ''
const add = () => this.next(() => {
if (text.trim()) {
todos.push({ id: Date.now(), text })
text = ''
}
})
while (true) {
const count = todos.length
yield (
<>
<input
set:value={text}
set:oninput={e => text = e.target.value}
set:onkeydown={e => e.key === 'Enter' && add()}
/>
<button set:onclick={add}>Add ({count})</button>
<ul>
{todos.map(t => <li key={t.id}>{t.text}</li>)}
</ul>
</>
)
}
}Call this.next() to trigger a re-render. The optional callback receives current args and its return value is passed through:
function* Stepper() {
let count = 0
// Access current args in callback
const inc = () => this.next(({ step = 1 }) => count += step)
for (const { step = 1 } of this) yield (
<button set:onclick={inc}>Count: {count} (+{step})</button>
)
}Use for...of this to receive fresh args each render cycle. Destructure in the parameter for values needed in init code:
function* Counter({ initial }) {
let count = initial // parameter destructuring for init code
for (const { step = 1 } of this) { // fresh args each cycle
yield <button set:onclick={() => this.next(() => count += step)}>+{step}</button>
}
}Use while (true) when you don't need args in the loop:
function* Timer() {
let seconds = 0
setInterval(() => this.next(() => seconds++), 1000)
while (true) yield <p>{seconds}s</p>
}Every stateful component has a this.signal (AbortSignal) that aborts when the component unmounts. Use it with any API that accepts a signal:
function* MouseTracker() {
let pos = { x: 0, y: 0 }
document.addEventListener('mousemove', e => this.next(() => {
pos = { x: e.clientX, y: e.clientY }
}), { signal: this.signal }) // auto-removed on unmount
while (true) yield <p>{pos.x}, {pos.y}</p>
}For APIs that don't accept a signal, use try...finally:
function* Clock() {
let time = new Date()
const interval = setInterval(() => this.next(() => time = new Date()), 1000)
try {
while (true) yield <p>{time.toLocaleTimeString()}</p>
} finally {
clearInterval(interval)
}
}Use try...catch inside the loop to catch errors and recover:
function* ErrorBoundary() {
for (const { children } of this) {
try {
yield children
} catch (error) {
yield (
<>
<p>Error: {error.message}</p>
<button set:onclick={() => this.next()}>Retry</button>
</>
)
}
}
}| Attribute | Description |
|---|---|
key |
Unique identifier for list reconciliation |
ref |
Callback receiving DOM element (or null on unmount) |
memo |
Skip reconciliation: memo={[deps]}, memo={value}, or memo (render once) |
skip |
Exclude children from reconciliation (required with set:innerHTML) |
set:* |
Set DOM properties instead of HTML attributes |
attr:* |
Force HTML attributes on stateful component wrappers |
// Events (always use set:)
<button set:onclick={handleClick}>Click</button>
// Dynamic values that need to sync with state
<input set:value={text} /> // DOM property (syncs)
<input value="initial" /> // HTML attribute (initial only)
<input type="checkbox" set:checked={bool} />
<video set:currentTime={0} set:muted />
// innerHTML requires skip
<div set:innerHTML={html} skip />function* AutoFocus() {
let input = null
while (true) yield (
<>
<input ref={el => el?.focus()} />
<button set:onclick={() => input?.select()}>Select</button>
</>
)
}
// Ref to stateful component includes control methods
let timer = null
<Clock ref={el => timer = el} />
timer?.next() // trigger re-render from outside<div memo={[user.id]}>...</div> // re-render when user.id changes
<div memo={count}>...</div> // re-render when count changes
<footer memo>Static content</footer> // render once, never updatefunction* Chart() {
let chart = null
for (const { data } of this) yield (
<div skip ref={el => el && (chart ??= new ChartLib(el, data))} />
)
}<Counter
initial={0} // → args
attr:id="main" // → wrapper HTML attribute
attr:class="widget" // → wrapper HTML attribute
set:onclick={fn} // → wrapper DOM property
/>Share data across component trees without prop drilling:
import { context } from 'ajo/context'
const ThemeContext = context('light')Stateless: read only. Stateful: read/write. Write inside the loop when the value depends on state, or outside for a constant value.
// Stateless - read only
const Card = ({ title }) => {
const theme = ThemeContext()
return <div class={`card theme-${theme}`}>{title}</div>
}
// Stateful - write inside loop (value depends on state)
function* ThemeProvider() {
let theme = 'light'
for (const { children } of this) {
ThemeContext(theme)
yield (
<>
<button set:onclick={() => this.next(() => theme = theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
{children}
</>
)
}
}
// Stateful - write outside loop (constant value)
function* FixedTheme() {
ThemeContext('dark') // set once at mount
for (const { children } of this) yield children
}function* UserProfile({ id }) {
let data = null, error = null, loading = true
fetch(`/api/users/${id}`, { signal: this.signal })
.then(r => r.json())
.then(d => this.next(() => { data = d; loading = false }))
.catch(e => this.next(() => { error = e; loading = false }))
while (true) {
if (loading) yield <p>Loading...</p>
else if (error) yield <p>Error: {error.message}</p>
else yield <h1>{data.name}</h1>
}
}import { render } from 'ajo/html'
const html = render(<App />)import type { Stateless, Stateful, WithChildren } from 'ajo'
// Stateless
type CardProps = WithChildren<{ title: string }>
const Card: Stateless<CardProps> = ({ title, children }) => (
<div class="card"><h3>{title}</h3>{children}</div>
)
// Stateful with custom wrapper element
type CounterProps = { initial: number; step?: number }
const Counter: Stateful<CounterProps, 'section'> = function* ({ initial }) {
let count = initial
for (const { step = 1 } of this) {
yield <button set:onclick={() => this.next(() => count += step)}>+{step}</button>
}
}
Counter.is = 'section' // wrapper element (default: 'div')
Counter.attrs = { class: 'counter' } // default wrapper attributes
Counter.args = { step: 1 } // default args
// Or use stateful() to avoid duplicating 'section':
// const Counter = stateful(function* ({ initial }: CounterProps) { ... }, 'section')
// Ref typing
let ref: ThisParameterType<typeof Counter> | null = null
<Counter ref={el => ref = el} initial={0} />| Export | Description |
|---|---|
render(children, container, start?, end?) |
Render to DOM. Optional start/end for targeted updates. |
stateful(fn, tag?) |
Create stateful component with type inference for custom wrapper. |
defaults |
Default wrapper tag config (defaults.tag). |
| Export | Description |
|---|---|
context<T>(fallback?) |
Create context. Call with value to write, without to read. |
| Export | Description |
|---|---|
render(children) |
Render to HTML string. |
html(children) |
Render to HTML generator (yields strings). |
| Property | Description |
|---|---|
for...of this |
Iterable: yields fresh args each render cycle. |
this.signal |
AbortSignal that aborts on unmount. Pass to fetch(), addEventListener(), etc. |
this.next(fn?) |
Re-render. Callback receives current args. Returns callback's result. |
this.throw(error) |
Throw to parent boundary. |
this.return(deep?) |
Terminate generator. Pass true to also terminate child generators. |
this is also the wrapper element (this.addEventListener(), etc).
See LLMs.md for a condensed reference.
ISC © Cristian Falcone
