Warning
Experimental alpha software. This is a research project, not a product. Nothing is published to npm, there is no versioning policy, and the API, the wire protocol, and the package names may all change without notice or a migration path. Read it, run the demos, take the ideas — but do not put it in production.
Write application state as async generators. The page updates only where the state changed, and the same process runs in a worker, another tab, or on your server.
Read the site: an overview, a guide, live demos, the documentation, and the example gallery.
A typical web app manages its state with several different tools: hooks or signals for component state, a store for shared state, a query cache for server data, a socket layer to keep live data in sync, and a workflow engine for long-running backend jobs. Each has its own API, lifecycle, and failure modes, and moving state from one to another means rewriting it. The same bugs recur in all of them: clicks interleaving with half-finished work, handlers holding an old render's props, missing dependencies, responses that land after the user has moved on, and re-renders caused by changes a component doesn't display.
Nonchalant tries one primitive for all of it, and it is one JavaScript already
has. An async generator keeps state in local variables, takes input in order
with for await, publishes with yield, and cleans up in finally.
Nonchalant runs it as a process: spawn returns a typed handle for reading
its state, sending it messages, and disposing 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 // the state: an ordinary variable
yield n // publish it
for await (const d of self) { // take each message, in order
n += d
yield n // publish the new state
}
}, undefined, { initial: 0 })
mount(document.getElementById('app')!, div({},
button({ onclick: () => counter.cast(-1) }, '−'),
span({}, counter), // a binding: updates itself, nothing else re-runs
button({ onclick: () => counter.cast(1) }, '+')))The view runs once. A process or a function placed in the tree is a binding that updates only its own spot on the page when the state it read changes. The same process can be shared by name through a registry, moved to a worker or a server by changing one line, or made durable so it survives a restart (a durable body restores from its last saved snapshot and runs side effects through recorded steps; docs/server.md has the rules). It is not a React component model, an Erlang runtime, or a full query client.
-
Views execute once. A view returns a tree containing live bindings. Updates do not call the view again, so there is no need to stabilize callbacks or maintain dependency arrays. Structure changes through keyed lists and replaceable regions: a binding that returns a subtree, which the renderer patches or swaps in place without touching the rest of the tree.
-
Updates are limited to affected readers. Write standard immutable updates and yield the next snapshot. Nonchalant compares it with the previous value and notifies readers only when a path they used has changed. CI verifies that changing one label in a 50-row list performs one DOM write. It also limits the 60 fps game demo to one view yield and at most two DOM writes in its busiest frame.
s = { ...s, total: s.total + item.price } // update immutably
yield s // diffed → only /total readers wake;
// a binding on items[3].done sleeps through it- The mailbox handles messages sequentially. A process's state never sees
two updates interleave; repeated submissions queue and are handled in turn.
What to do with input that arrives mid-work is a policy you choose: handle it
next, keep only the newest (
latest()), cancel the work in flight, or drop late answers.latest()does not cancel the request already running, and ordering does not extend across processes or to your server.
for await (const { q } of self.latest()) { // queued keystrokes conflate to the newest
results = await api.search(q, { signal: self.signal })
yield { q, results }
}- Requests and responses are typed. A one-way message is a
Cast; one that expects a response is aCall.call()returns a promise for that response and rejects if the process crashes. TypeScript prevents aCallfrom being passed tocastand aCastfrom being passed tocall.
type CartMsg =
| Cast<{ type: 'add'; item: Item }> // a cast
| Call<{ type: 'checkout' }, { ok: boolean; charged: number }> // a call
const res = await cart.call({ type: 'checkout' }) // res is typed; crash = rejection- One lookup interface works locally and remotely.
lookup(name, args)can provide a shared dependency, reuse a cached process by name and arguments, or address a remote process.connect(transport)changes where the lookup goes without changing its interface. Remote use still requires JSON-compatible values, network failure handling, and authentication on deployed hosts.
const shop = registry({ cart: define(cart) }) // this tab
// const shop = connect<Shop>(portTransport(new Worker(url))) // another thread
// const shop = connect<Shop>(broadcastChannelTransport('shop')) // another tab
// const shop = connect<Shop>(webSocketTransport('wss://…')) // another machine- Processes can be tested directly.
Selfis an interface implemented bychannel(), so tests can drive the generator without starting the runtime, installing fake timers, or creating a DOM (docs/testing.md).
const self = channel<Msg>() // a scripted mailbox
self.cast({ type: 'add', title: 'milk' })
const it = todosProc(self, undefined)
expect((await it.next()).value).toMatchObject({ todos: [{ title: 'milk' }] })- Sugar is optional and compiles to the primitive.
cellcovers widget state, andreducerwrites the common loop-switch-yield process as a function of state and message. Both produce an ordinary process, so the registry, the wire,durable, and the inspector treat them the same as a hand-written generator, and a reducer that later needs to await can be rewritten as one without its callers changing (the layers).
function count(n: number, msg: Msg): number {
switch (msg.type) {
case 'add':
return n + msg.by
}
}
const counter = reducer(() => 0, count) // a Proc, like any async generator- The wire protocol is language-independent. Eight JSON operations carry
state patches rather than markup or code. Other languages can implement a
host against the conformance vectors in
packages/wire/spec/. - One library where you would otherwise assemble a stack. Local and shared
state, rendering, a query cache, worker, tab, and server sync, durable
workflows, and an inspector all come from the same process model, instead
of separate libraries with separate lifecycles. Size is reported rather than
capped:
pnpm sizeprints what each entry point costs. - Text is never parsed as HTML. The DOM renderer creates elements and text
nodes directly and sets attributes with
setAttribute, so markup in application data stays inert text. That closes markup injection; it does not vet URLs, so a user-suppliedhrefstill deserves validation.
- No server-side rendering or hydration. The DOM renderer builds every node in the browser. A server runs processes and sends state, not HTML.
- Not published to npm. The packages build and pack (
pnpm verify:pack), but the scope is unclaimed; use the repository directly for now.
| 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 |
These libraries make different tradeoffs. The migration guide describes Nonchalant's costs, including the lack of built-in JSX ergonomics, explicit thunks for reactive expressions, and 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 and granularity budgets
pnpm size # what each entry point costs, min+gzip
pnpm check # strict TypeScript across packages, examples, the site, and doc samples
pnpm build:site # the static site, as GitHub Pages publishes it| doc | what it is |
|---|---|
| Thinking in processes | build a cart locally, then move it to a server |
| Concepts | the reference: each concept, its contract, its tests |
| API reference | every export of every package, with its signature |
| Error handling | crashes, stale values, call rejections, render and wire failures |
| Recipes | typeahead, forms, query cache, routing, undo/redo, drag, durability |
| Testing | driving generators directly, transcripts, views as data |
| Migration | coming from React, Solid, or LiveView |
| Processes on the server | virtual actors, durable execution, agent loops, and current limits |
| 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 |
| Contributing | commands, budgets, house style; security reports |
| package | contents |
|---|---|
@nonchalant/core |
Process, spawn, derive, the registry, reconcile, the reactive graph, and the optional cell and reducer sugar. Zero dependencies, no DOM. |
@nonchalant/dom |
tag constructors, h(), the DOM sink, keyed reconciliation, mount. |
@nonchalant/wire |
the protocol, codec, transports (WebSocket, worker port, BroadcastChannel, in-memory), connect. Isomorphic. |
@nonchalant/durable |
durable(proc): the message journal, the effect journal, durable calls, and the Store port. Isomorphic; ships the in-memory adapter. |
@nonchalant/host |
the Node WebSocket host: handshake authorization, origin policy, per-connection registry scoping, and connection limits. |
- The push-pull propagation core is ported from alien-signals by Johnson Chu (MIT). Nonchalant adds path-aware updates without changing the ported layer.
- Crank.js demonstrated how generator components can manage local state. Nonchalant combines that approach with live bindings, a mailbox, and a wire protocol instead of virtual DOM rerenders.
- Erlang/OTP informed mailboxes, casts and calls, restart-from-init-args, ownership, and named processes. Nonchalant does not provide process isolation, preemption, escalation, or OTP supervision trees.
- The Elm architecture informed the model/update/view pattern that several examples follow.
- Solid and lit-html informed the localized keyed diff.
- Phoenix LiveView informed the server-held UI state; Nonchalant's wire carries data patches instead of HTML.
- TanStack Query informed 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
are the basis for example and benchmark implementations in
examples/.
MIT © Tim Farland