One primitive. A process owns its state, takes messages in order, publishes snapshots, and does not care which side of the wire it runs on.
A generator already has the three things a piece of application state needs: local variables to hold it, a loop to take input in order, and an end. Nonchalant runs that generator and gives you a handle to it.
You write a process as an async generator. spawn runs it and hands back a typed handle you can read, send to, ask, iterate, and dispose. State is ordinary let variables; input is for await; output is yield. There is no store, no reducer registration, no dependency array.
import { spawn } from '@nonchalant/core'
import type { Self } from '@nonchalant/core'
import { mount } from '@nonchalant/dom'
import { button, div, span } from '@nonchalant/dom/tags'
const counter = spawn(async function* (self: Self<number>) {
let n = 0 // this is the state
yield n
for await (const d of self) { // this is the input
n += d
yield n // this is the output
}
}, undefined, { initial: 0 })
mount(document.getElementById('app')!, div({},
button({ onclick: () => counter.send(-1) }, '−'),
span({}, counter), // a live binding
button({ onclick: () => counter.send(1) }, '+')))
That same unit scales up without changing shape. A widget's state is a process. Shared application state is a process, reached by name. A cached query is a process with an idle timeout. A process on a server is the same process, reached through a transport instead of a function call.
The packages are not published yet — the npm scope claim and first release are still to come. For now it is a clone-and-explore library:
git clone https://github.com/twfarland/nonchalant
cd nonchalant
pnpm install
pnpm dev # this site at /, the example gallery at /examples/
pnpm test # the whole suite, including the perf, size, and DOM-write budgets
pnpm check # strict TypeScript across packages, examples, and this site
The process runtime, the reactive graph, the structural diff, and the registry. Zero dependencies, never touches the DOM.
Tag constructors and the DOM sink. Builds nodes with createElement only — no string is ever parsed as markup.
The eight-op protocol, its codec, and transports. Isomorphic and DOM-free, with cross-language conformance vectors.
The Node WebSocket host: origin policy, handshake authorization, per-connection scoping, and connection limits.
One noun with two faces. Process is what a holder of the running thing can do; Self is what the generator itself receives.
| From the outside | What it does |
|---|---|
p() | Read the latest value, synchronously. Inside a binding, effect, or derive this also subscribes — by path. Anywhere else it is just a read. |
p.send(msg) | Fire-and-forget. Exists only if the message type has plain messages. |
p.ask(msg) | Request/response, typed. Exists only if the message type has Call messages. Rejects if the process crashes, ends, or is disposed. |
p.pending / p.stale / p.error | Lifecycle as ordinary reads. Loading and failure states are values, not a separate mechanism. |
for await (v of p) | A lossy latest-value stream. You always get the newest, never a backlog. |
p[Symbol.dispose]() | Teardown: the mailbox closes, the signal aborts, finalizers run, owned children die with it. |
| From the inside | What it does |
|---|---|
for await (msg of self) | The mailbox, in order. Messages queue while you are busy — sequential handling is the default, so a double-submit queues instead of racing. |
self.latest() | Skip to the newest message and drop the rest. What a typeahead wants. |
self.signal | An AbortSignal that fires on dispose or crash. Thread it into every fetch. |
self.send(msg) | Post to your own mailbox — the actor self-send, for background work reporting back. |
A message that expects an answer is a Call, and the compiler keeps the two straight: it refuses to send a call or ask a cast.
type CartMsg =
| { type: 'add'; item: Item } // a cast
| Call<{ type: 'checkout' }, { ok: boolean; charged: number }> // a call
cart.send({ type: 'add', item }) // fine
const res = await cart.ask({ type: 'checkout' }) // res is typed; a crash rejects it
Every demo below is running on this page right now — the real library, no server anywhere. The listing under each one is the program above it: the same file, imported twice, once to run and once to read.
You write ordinary immutable updates. Every yield is diffed structurally against the previous snapshot, and a reader wakes only if a path it actually read is in the diff.
s = { ...s, total: s.total + item.price } // an ordinary immutable update
yield s // diffed → only /total readers wake;
// a binding on items[3].done sleeps through it
Nothing declares its dependencies. Reads inside a tracked context go through a short-lived recording proxy, so the set of paths a binding depends on is observed rather than annotated — which is why there are no dependency arrays, no memos, and no re-render tax to defend against. Views run once and never rebuild; structural change is expressed as keyed lists or swapped regions.
The claims are CI assertions, not adjectives:
| Budget | Enforced in |
|---|---|
| 1 changed row in 10,000 diffs in ≤ 100 µs | reconcile.perf.test.ts |
| One changed label in a 50-row list is one DOM write | dom.test.ts |
| A 60 fps game: 1 view yield, ≤ 3 DOM writes per frame, 0 structural ops | mario.golden.test.ts |
| core ≤ 8 KB gzip, core + DOM ≤ 13 KB, wire ≤ 9.5 KB | size.test.ts |
| Nothing retained after dispose | process.leaks.test.ts |
lookup(name, args) is one operation doing three jobs: dependency injection, query caching, and — once a transport is involved — remote addressing. Swapping a local registry for a connection keeps the interface, so the process and the view do not change:
const shop = registry({ cart: define(cart) }) // state lives in this tab
const shop = connect<Shop>(webSocketTransport('wss://…')) // state lives on a server
What crosses is a patch of plain data — never markup, never code — so the browser keeps rendering and local interaction, and any language can implement the host half against the conformance vectors. A crashed process on the host shows up here as stale: true with the last value intact; reconnecting is not a special case, just a re-lookup that answers with a full snapshot.
pnpm chat-server or pnpm cart-server, then open the page from pnpm dev. The shared-cart page also runs standalone with its state in the tab: one commented line swaps between the two. Before putting a host on the internet, read Hosting safely — origin policy, authorization, per-connection scoping, and the limits that are not on by default.One demo needs no server but does need two tabs: multi-tab elects one tab as the host over a BroadcastChannel, and moves the job when you close it. Open it twice.
This page is the short version. The written documentation goes considerably deeper, and every performance or granularity claim in it points at the test that enforces it.
| Document | What it is |
|---|---|
| Thinking in processes | The tutorial: build a cart, end with it on a server. |
| Concepts | The reference — each concept, its contract, and its test. |
| Recipes | Typeahead, forms, query cache, routing, undo/redo, drag, durable processes. |
| Testing | Driving generators directly; tests as message/yield transcripts. |
| Migration | Coming from React, Solid, or LiveView — including what you give up. |
| Hosting safely | Origins, authorization, connection scoping, deployment boundaries. |
| Protocol | The eight-op data wire and its conformance rules. |
| Internals | Contributor notes: how core is built, and the invariants that hold it together. |
alien-signals for the propagation core, which is a faithful port. Crank for the proof that generator components with plain-local state feel right. Erlang/OTP for mailboxes, casts and calls, and named processes. TanStack Query for cache keys, sharing, and idle eviction. Solid and lit-html for the localized keyed diff. LiveView for server-held state — with data patches here instead of HTML.