TypeScript  ·  MIT  ·  core + DOM under 13 KB gzipped

Nonchalant

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.


Async generators, plain let state Views run once Fine-grained updates, no compiler Experimental — not yet on npm
01

The idea

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.

What it is not. Not a React-compatible component model, not an Erlang runtime, not a complete query client. It is an experiment in how far one primitive goes — and it is honest about the edges, which are written down rather than glossed.
02

Get it

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

@nonchalant/core

The process runtime, the reactive graph, the structural diff, and the registry. Zero dependencies, never touches the DOM.

@nonchalant/dom

Tag constructors and the DOM sink. Builds nodes with createElement only — no string is ever parsed as markup.

@nonchalant/wire

The eight-op protocol, its codec, and transports. Isomorphic and DOM-free, with cross-language conformance vectors.

@nonchalant/host

The Node WebSocket host: origin policy, handshake authorization, per-connection scoping, and connection limits.

03

The primitive

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 outsideWhat 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.errorLifecycle 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 insideWhat 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.signalAn 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
04

Live demos

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.

Counter state in, messages out · click fast: the mailbox queues them
source
Todos one process owns the list · keyed rows
source
Typeahead self.latest() conflates queued input
source
Form ask() — a submit that learns its own outcome
source
Drag a gesture with a lifetime
source
Shared state lookup(name, args) is get-or-spawn
source
More, and bigger. The full ladder — TodoMVC, a router with code splitting, undo/redo as function composition, a query cache with write-through mutations, a canvas and a DOM renderer driven by one process, the seven GUIs, and a 60 fps Mario — is in the example gallery. Everything there runs in the browser alone except the chat demo, which wants a local WebSocket host.
05

Why the updates stay small

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:

BudgetEnforced in
1 changed row in 10,000 diffs in ≤ 100 µsreconcile.perf.test.ts
One changed label in a 50-row list is one DOM writedom.test.ts
A 60 fps game: 1 view yield, ≤ 3 DOM writes per frame, 0 structural opsmario.golden.test.ts
core ≤ 8 KB gzip, core + DOM ≤ 13 KB, wire ≤ 9.5 KBsize.test.ts
Nothing retained after disposeprocess.leaks.test.ts
06

Over the wire

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.

These demos need a terminal, not a server you have to deploy. The chat room and the shared cart run against a local Node host — 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.

07

Read more

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.

DocumentWhat it is
Thinking in processesThe tutorial: build a cart, end with it on a server.
ConceptsThe reference — each concept, its contract, and its test.
RecipesTypeahead, forms, query cache, routing, undo/redo, drag, durable processes.
TestingDriving generators directly; tests as message/yield transcripts.
MigrationComing from React, Solid, or LiveView — including what you give up.
Hosting safelyOrigins, authorization, connection scoping, deployment boundaries.
ProtocolThe eight-op data wire and its conformance rules.
InternalsContributor notes: how core is built, and the invariants that hold it together.

Prior art it stands on

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.