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.
A research project, not a product. Nothing is on npm, and the API and wire protocol may change without notice. Read it, run the demos, take the ideas, but don't ship it to production yet.
A typical web app manages its state with several different tools, each with its own rules.
Component state lives in hooks or signals. Shared state lives in a store. Server data lives in a query cache. Live updates need a socket layer that keeps a local copy in sync, and long-running backend jobs need a workflow engine. Each has its own API, its own lifecycle, and its own ways to fail, and moving a piece of state from one to another means rewriting it.
The same bugs turn up in all of them: two clicks racing each other, a callback reading a stale value, a dependency array missing an entry, a response arriving after the user has moved on, a component re-rendering because of a change it doesn't even display.
Nonchalant tries one primitive for all of it, and it's one JavaScript already has. An async generator can keep state in local variables, take input in order with for await, publish with yield, and clean up in finally. Nonchalant runs it as a process and handles the rest: delivering messages, updating the page, and carrying state across threads and networks.
A process owns one piece of state and handles messages one at a time. Here is a counter:
import { spawn } from '@nonchalant/core'
import type { Self } from '@nonchalant/core'
async function* counter(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
}
}
const count = spawn(counter, undefined, { initial: 0 })
count.cast(1) // send it a message
count() // read the latest state
spawn starts the generator and returns a handle. Its second argument is what the process is started with (this counter needs nothing), and initial is what a read returns before the first yield. Calling the handle, count(), reads the latest state. count.cast(1) puts a message in the process's mailbox. Messages are handled strictly in order, so three quick clicks always add three.
There is no store to configure and no reducer to register. The state is a let, the update logic is the loop body, and the process lives until its generator returns or you dispose it.
Views are built from tag functions and mounted once:
import { mount } from '@nonchalant/dom'
import { button, div, span } from '@nonchalant/dom/tags'
mount(document.getElementById('app')!, div({},
button({ onclick: () => count.cast(-1) }, '−'),
span({}, count), // a binding: shows the count
button({ onclick: () => count.cast(1) }, '+'),
span({}, () => (count() % 2 === 0 ? ' even' : ' odd')))) // so is a function
The view runs once and creates real DOM nodes. A process or a function placed in the tree is a binding: a live spot that updates itself when the state it read changes. Nothing else runs again. There is no re-render, so there are no dependency arrays or memoization, and an event handler never holds the props of an old render. (A closure can still keep a value you copied into it; read the process when you want the current one.)
Each of these gets a full explanation later; the link under each one goes there.
State is ordinary immutable objects and arrays, not signals or observable stores. Each yield is compared with the previous one, and only the bindings that read a changed value run again. Editing one row of a long list writes to that row alone, though the work of finding that row still grows with the list.
Each process handles one message at a time, so its state never sees two updates interleave. What happens to input that arrives mid-work is a choice you write per process: handle it next, keep only the newest, cancel the work in flight, or drop answers that come back late. Ordering stops at the process: two processes can still act on the same record.
Input, in order →A process doesn't know where it runs. One line moves it into a Web Worker, another tab, or a server, and the code that reads it stays the same. What it can do wrong changes: across a connection its value can go stale and a call can fail, and both show up on the same handle. Only small patches of data cross the connection, never HTML.
Run it anywhere →The same shape covers a toggle, shared app state, a cached query, a chat room on a server, an order that survives a crash, and an AI agent that waits for a person's approval. One library where you would otherwise assemble a stack.
Servers, workflows, and agents →A process is a generator, so a test sends it messages and checks what it yields. Clocks and services are passed in as messages and arguments, so tests run without a DOM, a network, or real delays.
Testing →The claims about update precision are assertions in CI: exact DOM-write counts for a game and a 1,000-row benchmark, a bound on diff time, and checks that nothing leaks after disposal. End-to-end timings are measured separately, with the slow cases shown.
The numbers →These demos run on this page using the library directly. Under each one, source shows the file that is running, so the listing and the demo are always the same code.
The first one is where the pieces meet. A durable job imports twelve records on a simulated worker in this tab, and the page reads it over the wire like any other process. Disconnect the client and the job keeps going while the page shows its last value as stale, then catches up when you reconnect. Kill the worker and it restarts from its last saved state: records already written are not written again, and a write that was in flight at the kill is retried under the same idempotency key, which the destination applies once. The counts on each row come from the destination, not from the job. Records show as written the moment they are, but they are saved when the job moves on, so a kill can briefly send one back to queued before the replay writes it.
The job's full source and its failure tests are in the example gallery. There are more demos further on: a form, a drag gesture, and a game in the guide, a Web Worker in Run anywhere, and an AI agent in Server & agents. The example gallery adds TodoMVC, routing, undo and redo, a query cache, the 7GUIs, and the js-framework-benchmark app.
Nonchalant borrows from projects you may already know:
| If you know | You'll recognize |
|---|---|
| Erlang/OTP | Processes with mailboxes, cast and call, processes addressed by name, and children that end with their parent. |
| Solid and signals | Fine-grained updates with no virtual DOM. The propagation engine is a port of alien-signals. |
| Crank | Generators that keep their own state between updates. |
| TanStack Query | Entries cached by their arguments, shared by every reader, and dropped when idle. |
| Phoenix LiveView | State owned by a server and pushed to the browser, except as data patches rather than HTML. |
| Temporal, Restate | Workflows that survive a restart because what they did was written down. |
What's new is putting all of these behind one primitive. A piece of state can grow from a widget into shared state and move to a server without being rewritten, and the code that reads it doesn't change along the way. Making it durable keeps the same shape but asks more of the body: it restarts from its last saved snapshot rather than resuming mid-generator, and its side effects go through recorded steps. Server & agents spells out what changes.
div({}, …) rather than JSX, and there is no re-render to hook into.Nothing is on npm yet. To try it, clone the repository:
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 update-count budgets
pnpm check # strict TypeScript across packages, examples, and this site
It is split into packages so an app takes only what it uses:
| Package | What it gives you |
|---|---|
@nonchalant/core | Processes, the registry, and the reactive graph. No dependencies and no DOM, so it runs anywhere JavaScript does. |
@nonchalant/dom | Tag functions and the renderer. Text is never parsed as HTML. |
@nonchalant/wire | Connections to processes in a worker, another tab, or a server. |
@nonchalant/durable | Processes that survive a crash or a restart, over a storage interface you can implement for your database. |
@nonchalant/host | A Node WebSocket server for processes, with origin checks, authorization, and rate limits. |
@nonchalant/inspect | A panel that shows live processes, the messages between them, and their past states. |
The whole model, one idea at a time: processes, messages, views, how updates stay small, and shared state.
Start the guide →Move a process into a worker, another tab, or a server, and what crosses the connection.
Run it anywhere →State that survives a crash, timers that survive a restart, and AI agents with tools and human approval.
On the server →Every example, each with a short explanation of how it works beside it.
Open the gallery →The documentation goes deeper: a tutorial, each concept with the tests that enforce it, the full API, recipes, notes for React, Solid, and LiveView users, and the wire protocol.