Processes, messages, views, updates, and shared state, each building on the one before. The counter from the overview is all you need to start.
A process is an async generator run by spawn. In a real app you name its three types with Proc<State, Message, Args>: what it yields, what messages it accepts, and what it is started with.
import { spawn } from '@nonchalant/core'
import type { Cast, Proc } from '@nonchalant/core'
type Msg = Cast<{ type: 'add'; by: number }> | Cast<{ type: 'reset' }>
const counter: Proc<number, Msg, { start: number }> = async function* (self, { start }) {
let n = start
yield n
for await (const msg of self) {
switch (msg.type) {
case 'add':
n += msg.by
break
case 'reset':
n = start
break
}
yield n
}
}
const c = spawn(counter, { start: 10 }, { initial: 10 })
c.cast({ type: 'add', by: 5 })
Messages are a union of object types told apart by their type field, and the loop handles them with a switch, one case per message. The loop body plays the part other libraries give to a reducer, except that it can also await, keep private variables, and yield more than once.
State is updated immutably: reassign the let with a new object, spreading in whatever didn't change, as in s = { ...s, total }. The parts that didn't change stay identical, which is what makes updates cheap (section 06 explains why).
The third argument to spawn holds options. Besides initial, the most useful is restart: 'on-crash', which reruns the generator from its arguments if it throws.
There are two kinds of message. A cast is one-way: the sender doesn't wait. A call expects an answer: the sender gets a promise, and the process answers by calling msg.reply. The message type says which is which:
type Item = { id: string; name: string; price: number }
type Cart = { items: Item[]; total: number }
type CartMsg =
| Cast<{ type: 'add'; item: Item }>
| Cast<{ type: 'clear' }>
| Call<{ type: 'checkout' }, { ok: boolean; charged: number }>
const cart: Proc<Cart, CartMsg, void> = async function* (self) {
let s: Cart = { items: [], total: 0 }
yield s
for await (const msg of self) {
switch (msg.type) {
case 'add':
s = { items: [...s.items, msg.item], total: s.total + msg.item.price }
break
case 'clear':
s = { items: [], total: 0 }
break
case 'checkout':
msg.reply({ ok: s.items.length > 0, charged: s.total })
continue // answered, state unchanged: no yield
}
yield s
}
}
Inside the loop, break leaves the switch and reaches the yield at the bottom. continue skips it, which is how a process says "nothing changed, publish nothing". From the outside:
const shop = spawn(cart, undefined, { initial: { items: [], total: 0 } })
shop.cast({ type: 'add', item: boots })
const receipt = await shop.call({ type: 'checkout' }) // typed: { ok: boolean; charged: number }
TypeScript routes each message: cast refuses a call, call refuses a cast, and the reply is typed. If the process crashes, finishes, or is disposed before it answers, the promise rejects instead of hanging.
Every handle also has three status reads: pending (the process is working toward its next yield), stale (the value survived a crash or a lost connection), and error (the last failure). They are reads like any other, so a view shows them the same way it shows state.
Messages wait in the mailbox and are handled one at a time. While a process is busy, awaiting a request for example, new messages queue behind it instead of interleaving with it. So a process's own state never sees two updates at once. That is the whole guarantee. It doesn't decide what should happen to input that arrives while the process is busy, it doesn't coordinate two processes, and it doesn't stop your server from receiving the same request twice.
What to do with input that arrives mid-work is a policy, and there are four common ones. Each is a few lines, and you pick per process:
| Policy | What it does | How it's written |
|---|---|---|
| In order | Every message is handled, one after another. A double-clicked submit is handled twice, in order. If the second one should do nothing, the handler says so. | for await (const msg of self), with if (s.submitting) continue where a repeat must be ignored |
| Newest only | When the loop asks for its next message, older queued ones are dropped. Work already running is not touched: it finishes, and what it yields is published before the loop looks at the queue. | for await (const msg of self.latest()) |
| Cancel in flight | A new message aborts the work started for the previous one. | Run the work beside the loop with its own AbortController, and abort it on the next message |
| Drop late answers | A result that comes back after a newer request was made is ignored, not shown. | Number each request, and have the result carry its number back |
The typeahead on the overview uses the last two together. The search runs beside the loop and reports back with self.cast, so the mailbox keeps listening while it waits:
for await (const msg of self) {
switch (msg.type) {
case 'query': {
inflight.abort() // cancel the search in flight
inflight = new AbortController()
const mine = ++seq
search(msg.q, { signal: AbortSignal.any([self.signal, inflight.signal]) }).then(
(results) => self.cast({ type: 'found', seq: mine, results }),
() => {}, // aborted: a newer query or disposal
)
s = { ...s, q: msg.q, pending: true }
break
}
case 'found':
if (msg.seq !== seq) continue // answered after a newer query: drop it
s = { ...s, results: msg.results, pending: false }
break
}
yield s
}
With self.latest() instead, the loop is shorter and has no timer, but the search for a stale query runs to the end and its results appear briefly before the newest query is picked up. That is fine for some inputs and wrong for others, which is why it's a choice. The gallery typeahead shows that version.
self.signal is an AbortSignal that fires when the process is disposed or crashes. Pass it to every fetch, and work that belongs to a process that no longer exists is cancelled. It is not a per-message signal: cancelling one request in favour of the next is the "cancel in flight" policy above.
Ordering stops at the process boundary. Two processes, or two tabs, or two servers, can still act on the same record, and a request can still reach your API twice after a retry. Those need the usual tools: one process that owns the record, a database constraint, or an idempotency key.
A process ends when its generator returns, when it throws (unless it is set to restart), or when it is disposed:
c[Symbol.dispose]() // start teardown now
await c[Symbol.asyncDispose]() // or: start it, and wait until every finally block has run
Disposal closes the mailbox, aborts self.signal, and runs the generator's finally blocks. Anything a process spawns belongs to it and is disposed with it, so cleanup follows structure: dispose a page and everything the page started goes too.
Inside a process, start children with self.spawn. It takes the same arguments as spawn, and the child belongs to the process wherever the call is, before or after an await:
const page: Proc<VNode, never, void> = async function* (self) {
const { Chart } = await import('./chart.ts') // an await first
const chart = self.spawn(Chart, undefined) // still owned: disposed with the page
yield div({}, chart)
for await (const _ of self) void _ // stay alive until disposed
}
spawn? A bare spawn inside a process also attaches to it, but only in the synchronous part of a step. After an await it runs unowned, so moving it below an await would quietly change how long it lives. Use plain spawn inside a body only for a child that should outlive the process.Processes are cheap enough to be short-lived. This demo starts one per drag: it begins on pointer-down, yields a position for each move, and is disposed on pointer-up, taking its subscriptions with it.
Views are plain function calls. @nonchalant/dom/tags exports one function per HTML element, called as div(attributes, ...children), and h('my-element', attributes, ...children) covers any other tag. Nothing is parsed from strings, so text from your data can never turn into markup.
A component is an ordinary function that takes the processes it needs and returns a tree:
function Summary(shop: Process<Cart, CartMsg>): VNode {
return div({},
span({}, () => `${shop().items.length} items, $${shop().total}`), // a text binding
button({
disabled: () => shop().items.length === 0, // an attribute binding
onclick: () => shop.cast({ type: 'clear' }),
}, 'clear'),
ul({}, () => shop().items.map((item) => li({ key: item.id }, item.name)))) // a keyed list
}
The function runs once. Each function inside the tree is a binding, whether it sits in a child position or an attribute, and so is a process placed directly in the tree. A binding runs again whenever something it read changes, and it updates only its own spot on the page. (onclick is different: it's an event handler, called when the event fires.)
Structure changes happen inside bindings too. A binding that returns elements is a region, which the renderer patches in place. A binding that returns an array of elements with keys is a keyed list: rows are matched by key, and only the rows that were added, removed, or moved touch the DOM.
Outside a view, two functions subscribe in the same way:
const itemCount = derive(() => shop().items.length)
const stop = effect(() => {
document.title = `Cart (${itemCount()})`
})
derive computes a value from other processes and can be read like a process itself; it notifies its readers only when its result actually changes. effect runs a side effect now, and again whenever what it read changes. It is for pushing state to things outside Nonchalant, such as the document title, and it returns a function that stops it.
shop() inside a binding, derive, or effect subscribes to what was read. Anywhere else, such as an event handler, it returns the current value and subscribes to nothing.Libraries usually make you choose between plain data and precise updates. Nonchalant gives you both.
With plain immutable data (React, Redux), state is easy to reason about, but a change re-runs whole components, and you memoize to win back the cost. With reactive containers (signals, observable stores), updates are precise, but your state is no longer plain data. Nonchalant keeps plain data and makes updates precise in three steps:
total or items[3].done.
s = { ...s, total: s.total + item.price } // an ordinary immutable update
yield s // the diff: total changed
// () => cart().total runs again
// () => cart().items[3].done does not
There is no compiler and no dependency list. What it asks of you is the update style: reassign with let and spread. Mutating an object in place hides the change from the diff, and deep-cloning everything makes every part look changed.
This demo ports the physics of the classic Elm Mario example. One process holds the world. Animation frames and arrow keys arrive as messages, and only frames move the world forward, so holding a key can never advance the physics twice in one frame. The view is built once. After that, the busiest frame makes two DOM writes, and no element is ever replaced.
These numbers are assertions in the test suite, so a change that breaks one fails the build:
| What is checked | Where |
|---|---|
| Changing one label in a 50-row list is one DOM write | dom.test.ts |
| The game above: the view is built once, the busiest frame makes at most 2 DOM writes, and nothing is replaced | mario.golden.test.ts |
| Swapping two of 1,000 keyed rows is exactly 2 DOM moves | bench.test.ts |
| Selecting a row in 1,000 is exactly 2 class writes | bench.test.ts |
| Appending to 1,000 rows inserts 1 row, writes to none of the others, and re-creates none of their bindings | bench.test.ts |
| Updating every 10th of 1,000 rows is exactly 100 text writes | bench.test.ts |
| Diffing a 10,000-row state with one changed row takes at most 100 µs | reconcile.perf.test.ts |
| Nothing is retained after a process is disposed | process.leaks.test.ts |
These budgets assert update precision: which bindings re-run and exactly which DOM writes an update makes. Those counts don't depend on the machine. Precise is not the same as cheap, though. Finding the one row to write in a 10,000-row list still walks the list, and a list binding that maps over every row rebuilds every row's description. How long the whole path takes, from message to settled DOM and over the wire, is measured separately and not gated. Performance has the numbers and what to do about them.
A process that one component uses can be spawned right where it's needed. State that several parts of an app need, or that is expensive to create, goes in a registry:
import { define, registry } from '@nonchalant/core'
const app = registry({
cart: define(cart, { initial: { items: [], total: 0 } }),
product: define(product, { evict: 60_000 }), // dropped a minute after its last reader leaves
})
const mine = app.lookup('cart') // the cart, wherever it is asked for
const p1 = app.lookup('product', { id: 'boots' }) // starts the process
const p2 = app.lookup('product', { id: 'boots' }) // the same process as p1
lookup(name, args) is get-or-spawn. The first lookup starts the process; later lookups with the same name and arguments return the same one. That one mechanism does three jobs:
evict drops the entry a set time after its last reader leaves.lookup, so the same call can reach a process somewhere else. That's the next page.Everything so far is the generator. Two common cases have a shorter way to write one. Each produces an ordinary process, so everything on this site treats it the same way:
| Shorthand | What it stands for |
|---|---|
cell(initial) | A process whose messages are its next values: widget state, the useState case. |
reducer(init, reduce) | The loop, switch, and yield process, written as a function from state and message to the next state. |
Here is a counter written with reducer. Returning the same state means nothing changed, so nothing is yielded:
import { reducer } from '@nonchalant/core'
import type { Call, Cast } from '@nonchalant/core'
type Msg = Cast<{ type: 'add'; by: number }> | Call<{ type: 'get' }, number>
function count(n: number, msg: Msg): number {
switch (msg.type) {
case 'add':
return n + msg.by
case 'get':
msg.reply(n)
return n // the same state: nothing is yielded
}
}
const counter = reducer(() => 0, count)
A reducer is synchronous and keeps no private state. Once a process needs to await, yield more than once for a message, or clean up in finally, write the generator instead; its messages and its callers stay the same.
A process keeps its state inside a suspended generator, where browser devtools can't see it. @nonchalant/inspect can. It records every spawn, message, reply, yield, crash, and exit, and shows the live process tree, a timeline of events, and the state of any process at any recorded moment.
import { mountInspector } from '@nonchalant/inspect'
const panel = mountInspector(document.getElementById('inspector')!) // before the processes it should see
Yields are stored as the same patches the diff produces, so stepping back in time replays them instead of keeping a copy of every state. To try it, add ?inspect to the TodoMVC or agent example.
A handle, from the outside:
| Member | What it does |
|---|---|
p() | Reads the latest value. Inside a binding, derive, or effect, it also subscribes to the paths read. |
p.cast(msg) | Sends a one-way message. |
p.call(msg) | Sends a request and returns a promise of the typed reply. Rejects if the process crashes, finishes, or is disposed first. |
p.pending, p.stale, p.error | Working toward the next yield; the value survived a failure; the last failure. |
for await (const v of p) | Iterates over values. A slow reader gets the newest value, never a backlog. |
p[Symbol.dispose]() | Starts teardown now. |
await p[Symbol.asyncDispose]() | Starts teardown and waits for it, children included, to finish. |
And self, from the inside:
| Member | What it does |
|---|---|
for await (const msg of self) | Takes queued messages in order. |
self.latest() | The same loop, skipping to the newest message each time. |
self.signal | An AbortSignal that fires on disposal or a crash. |
self.cast(msg) | Sends a message to the process's own mailbox, so background work can report back. |
Every export of every package is in the API reference, and each concept's exact behavior is in Concepts.