Nonchalant is an experimental TypeScript runtime for stateful async-generator
processes, with optional DOM and wire packages. A process owns ordinary local
state, receives messages sequentially, publishes snapshots, and has an
explicit lifetime. spawn returns a typed handle that you can read, send to,
ask, iterate, and dispose.
The project explores a specific idea: can the same state-owning unit work for widget state, shared application state, cached work, and state reached over a transport? It is not a React-compatible component model, an Erlang runtime, or a complete query client. The useful part is the combination of sequential messages, fine-grained snapshot reads, ownership, and a small data-only wire.
twfarland.github.io/nonchalant — the short version, with the demos running on the page and the whole example gallery alongside it.
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) }, '+')))Generators uniquely combine three pieces: local state as ordinary let variables,
sequential input as for await messages, and an explicit lifetime (return or
dispose). This single unit scales—a cell's state machine is the same shape as
a cached query, which is the same shape as a process running on a server. One
process type, three distances. registry.lookup is dependency injection + query
cache + remote addressing rolled into one operation. Views run once; structure
never rebuilds; updates flow through the graph by path; everything is plain data
until it crosses a wire.
-
Views run once. A view returns a tree with bindings in it, and never rebuilds. All the React muscle memory about defending against re-renders— memoization, dependency arrays, stable identities—has nothing to attach to. Structure that changes is expressed as keyed lists or swapped regions.
-
Fine-grained updates come free. You write ordinary immutable updates; every yield is diffed structurally, and readers wake only if a path they actually read changed. This falls out of the model: immutable yield + structural diff + read tracking = no dependency arrays, no memos, no re-render tax. CI asserts that changing one label in a 50-row list is exactly one DOM write, and that a 60 fps game demo stays within one view yield and ≤ 3 DOM writes per frame.
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- The mailbox serializes work by default. A double-submit queues instead of
racing;
latest()conflates queued input to the newest value, while the abort signal handles lifetime cancellation.
for await (const { q } of self.latest()) { // queued keystrokes conflate to the newest
results = await api.search(q, { signal: self.signal })
yield { q, results }
}- Request/response is typed end to end. A message that expects an answer
is a
Call;ask()returns the reply as a promise and rejects if the process crashed. The compiler refuses tosenda call oraska cast.
type CartMsg =
| { type: 'add'; item: Item } // a cast
| Call<{ type: 'checkout' }, { ok: boolean; charged: number }> // a call
const res = await cart.ask({ type: 'checkout' }) // res is typed; crash = rejection- One interface for DI, caching, and remote addressing.
lookup(name, args)is simultaneously dependency injection (no prop drilling), query caching (name + args = TanStack's queryKey, with refcounting and idle eviction), and named addressing.connect(transport)substitutes the transport but keeps the interface; the same code works locally or over a wire. Real boundaries remain: arguments and values must be JSON, calls fail on network loss, and a deployed host needs authentication.
const shop = registry({ cart: define(cart) }) // local
// const shop = connect<Shop>(webSocketTransport('wss://…')) // remote, same interface- Processes test as transcripts.
Selfis an interface andchannel()implements it, so a process tests as the plain generator it is — no runtime, no fake timers, no DOM (docs/testing.md).
const self = channel<Msg>() // a scripted mailbox
self.send({ type: 'add', title: 'milk' })
const it = todosProc(self, undefined)
expect((await it.next()).value.todos).toHaveLength(1)- A language-agnostic wire. Eight JSON ops carrying state patches — never
markup, never code. The conformance vectors in
packages/wire/spec/are the contract; any language can implement the host half. - Small, with enforced limits. CI keeps core at or below 8 KB gzipped and core + DOM + tags at or below 13 KB gzipped.
| you write | state lives in | updates happen by | state addressable over the wire | |
|---|---|---|---|---|
| React | functions, re-run every update | hooks | re-render + vdom diff | no |
| Solid | functions, run once | signals / stores | fine-grained graph | no |
| Svelte 5 | compiled components | $state runes |
compiler-injected updates | no |
| Crank | generator components + JSX | plain locals | re-render + vdom diff | no |
| LiveView | server templates | server assigns | HTML diffs over the wire | server-only |
| nonchalant | generator processes | plain let locals |
yield → diff → wake by path | local or remote registry lookup |
Every row is a different set of trade-offs, not a scoreboard. What nonchalant gives up is listed in the migration guide: no JSX ergonomics without an adapter, explicit thunks for reactive expressions, no BEAM-style preemption.
pnpm install
pnpm dev # the doc site at /, the example gallery at /examples/
pnpm test # the whole suite, including the perf/size/granularity budgets
pnpm check # strict TypeScript across packages, examples, and the site
pnpm build:site # the static site, as GitHub Pages publishes it| doc | what it is |
|---|---|
| Thinking in processes | the tutorial — build a cart, end with it on a server |
| Concepts | the reference: each concept, its contract, its tests |
| Recipes | typeahead, forms, query cache, routing, undo/redo, drag |
| Testing | driving generators directly, transcripts, views as data |
| Migration | coming from React, Solid, or LiveView |
| Hosting safely | authentication, browser origins, and deployment boundaries |
| Protocol | the data wire and conformance rules |
| Examples | the demo ladder |
| Internals | contributor notes: how core is built, and its invariants |
| package | contents |
|---|---|
@nonchalant/core |
Process, spawn, derive, the registry, reconcile, the reactive graph. Zero dependencies, no DOM. |
@nonchalant/dom |
tag constructors, h(), the DOM sink, keyed reconciliation, mount. |
@nonchalant/wire |
the protocol, codec, transports (WebSocket, BroadcastChannel, in-memory), connect. Isomorphic. |
@nonchalant/host |
the Node WebSocket host: handshake authorization, origin policy, per-connection registry scoping, and connection limits. |
- alien-signals (Johnson Chu, MIT) — the push–pull propagation core is a faithful port; the path-precision layer sits on top of it, untouched.
- Crank.js — a major influence: the proof that generator components with plain-local state feel right. Nonchalant keeps the generator and swaps the vdom re-render for fine-grained bindings, a mailbox, and the wire.
- Erlang/OTP — inspiration for mailboxes, casts vs calls, restart-from-init-args, ownership, and named processes. Nonchalant does not provide process isolation, preemption, escalation, or OTP supervision trees.
- The Elm architecture — the model/update/view lineage several of the examples follow.
- Solid and lit-html — prior art for the localized keyed diff.
- Phoenix LiveView — prior art for server-held UI state; nonchalant's wire carries data patches instead of HTML.
- TanStack Query — prior art for cache keys, sharing, watcher counts, and idle eviction. The registry implements those lifecycle pieces, not the full product surface of a query client.
- 7GUIs (Eugen Kiss), TodoMVC, and
the krausest js-framework-benchmark
— the example and benchmark suites implemented in
examples/.
MIT © Tim Farland