TypeScript  ·  MIT  ·  browser, worker, and server

Nonchalant

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.

Alpha

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.

01

Why it exists

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.

02

A process in one minute

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.

Putting it on the page

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.)

Counter click quickly: every click is queued and applied in order
source
03

What's different

Each of these gets a full explanation later; the link under each one goes there.

Plain data, precise updates

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.

How updates stay small →

Input, in order

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 →

Location doesn't matter

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 →

One model, front to back

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 →

Tests without a browser

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 →

Measured, not promised

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 →
04

See it run

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.

A long-running job start it, then disconnect the client or kill the worker mid-run · the counts are the destination's own
source
Todos one process owns the list · toggling a row updates only that row
source
Typeahead type quickly: each keystroke cancels the search in flight, and a late answer is never shown
source

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.

05

Where it fits

Nonchalant borrows from projects you may already know:

If you knowYou'll recognize
Erlang/OTPProcesses with mailboxes, cast and call, processes addressed by name, and children that end with their parent.
Solid and signalsFine-grained updates with no virtual DOM. The propagation engine is a port of alien-signals.
CrankGenerators that keep their own state between updates.
TanStack QueryEntries cached by their arguments, shared by every reader, and dropped when idle.
Phoenix LiveViewState owned by a server and pushed to the browser, except as data patches rather than HTML.
Temporal, RestateWorkflows 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.

What it isn't

06

Get started

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:

PackageWhat it gives you
@nonchalant/coreProcesses, the registry, and the reactive graph. No dependencies and no DOM, so it runs anywhere JavaScript does.
@nonchalant/domTag functions and the renderer. Text is never parsed as HTML.
@nonchalant/wireConnections to processes in a worker, another tab, or a server.
@nonchalant/durableProcesses that survive a crash or a restart, over a storage interface you can implement for your database.
@nonchalant/hostA Node WebSocket server for processes, with origin checks, authorization, and rate limits.
@nonchalant/inspectA panel that shows live processes, the messages between them, and their past states.
07

Read on

The guide

The whole model, one idea at a time: processes, messages, views, how updates stay small, and shared state.

Start the guide →

Run it anywhere

Move a process into a worker, another tab, or a server, and what crosses the connection.

Run it anywhere →

Servers, workflows, and agents

State that survives a crash, timers that survive a restart, and AI agents with tools and human approval.

On the server →

The example gallery

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.