Thinking in processes

  1. 1. State is a process
  2. 2. Reading doesn't subscribe
  3. 3. Use immutable updates
  4. 4. Why structural sharing matters
  5. 5. Views run once
  6. 6. When you need an answer, call
  7. 7. Share by name
  8. 8. Use a remote registry
  9. Where to next

This tutorial builds a working cart and then connects the same view to either a local or server-backed registry. The code is runnable and is drawn largely from the examples/ directory.

1. State is a process

In Nonchalant, a process owns a piece of state. Processes are async generators: local variables hold state, the mailbox supplies input, and each yield publishes a snapshot. spawn runs the generator and returns the handle used to read it, cast and call, and dispose it.

import { spawn } from '@nonchalant/core'
import type { Cast, Proc } from '@nonchalant/core'

type CounterMsg = Cast<{ type: 'add'; n: number }>

const counter: Proc<number, CounterMsg, void> = async function* (self) {
  let n = 0                       // local state
  yield n                         // publish it
  for await (const msg of self) { // wait for messages
    n += msg.n
    yield n                       // publish again
  }
}

const p = spawn(counter, undefined, { initial: 0 })
p()                    // read the current value: 0
p.cast({ type: 'add', n: 5 })
// a moment later: p() === 5

No store setup or reducer registration is required. The generator preserves its local variables while suspended between messages, and yield publishes the result. The initial option determines whether reads can be undefined, while the message union determines what cast accepts.

2. Reading doesn't subscribe

Outside a tracked context, p() returns a snapshot without subscribing:

if (p() > 10) celebrate()   // reads the value now, remembers nothing

Subscriptions are created only where you ask for them. derive computes a value from other processes and recomputes when what it read changes. effect runs a side effect now and again whenever what it read changes, and returns a function that stops it. Iterating a process with for await is an explicit subscription too. Reads anywhere else do not create dependencies, so a process can inspect other processes without subscribing to them.

import { derive, effect } from '@nonchalant/core'

const doubled = derive(() => p() * 2)                       // recomputes when p yields
const stop = effect(() => { document.title = `${p()}` })    // re-runs when p yields
for await (const v of p) console.log(v, doubled())          // an explicit subscription
stop()

3. Use immutable updates

Yield new objects while reusing values that have not changed:

import type { Cast, Proc } from '@nonchalant/core'

type Item = { id: number; name: string; price: number }
type Cart = { items: Item[]; total: number }
type CartMsg = Cast<{ type: 'add'; item: Item }>

const cartProc: Proc<Cart, CartMsg, void> = async function* (self) {
  let s: Cart = { items: [], total: 0 }
  yield s
  for await (const msg of self) {
    switch (msg.type) {
      case 'add':
        s = { ...s, items: [...s.items, msg.item], total: s.total + msg.item.price }
        break
    }
    yield s
  }
}

Each yield is compared with the previous snapshot. When unchanged branches keep the same object identity, the comparison can skip them. The resulting patch drives updates, so renaming one item does not notify a binding that only read cart().total.

The one thing to avoid: mutating your state and yielding a deep clone. It works, but then nothing is shared and the diff has to look at everything.

4. Why structural sharing matters

In the repository benchmark, changing one field in 10,000 items takes about 50 µs to diff. Unchanged objects keep the same references, allowing the diff to skip them in constant time. Tracked reads separate a dependency on total from one on items, and patches notify only readers of affected paths. The test suite checks notification counts, while the Mario demo is limited to one view yield and at most two DOM writes in its busiest frame.

5. Views run once

A view is a function that returns a tree. Add a binding, either a thunk or a process, wherever the tree needs live data. The surrounding tree is created once:

import { spawn } from '@nonchalant/core'
import type { Process, VNode } from '@nonchalant/core'
import { mount } from '@nonchalant/dom'
import { button, div, li, span, ul } from '@nonchalant/dom/tags'

const menu = [{ name: 'tea', price: 3 }, { name: 'cake', price: 5 }]
let nextId = 0
const pick = (): Item => {
  const id = nextId++
  return { id, ...menu[id % menu.length]! }
}

function CartView(cart: Process<Cart, CartMsg>): VNode {
  return div({},
    ul({}, () => cart().items.map((it) =>
      li({ key: it.id }, it.name))),             // a keyed list
    span({}, () => String(cart().total)),        // wakes only when total changes
    button({ onclick: () => cart.cast({ type: 'add', item: pick() }) }, 'Add'))
}

const cart = spawn(cartProc, undefined, { initial: { items: [], total: 0 } })
mount(document.getElementById('app')!, CartView(cart))

The function runs once. Later changes pass through bindings to the DOM nodes they affect. A widget can close over its own process, as examples/counter does with cell(0), a small wrapper around spawn. Cells created inside a view process belong to it and are disposed when it ends.

6. When you need an answer, call

cast does not wait for a response. When a caller needs the result, such as a form checking whether its submission succeeded, use a Call with call. TypeScript prevents a Call from being cast and a Cast from being called.

type CartMsg =
  | Cast<{ type: 'add'; item: Item }>
  | Call<{ type: 'checkout' }, { ok: boolean; charged: number }>

// inside the generator, one case per message; a call carries a reply function:
switch (msg.type) {
  case 'add':
    s = { ...s, items: [...s.items, msg.item], total: s.total + msg.item.price }
    break
  case 'checkout':
    msg.reply({ ok: true, charged: s.total })
    s = { items: [], total: 0 }
    break
}

// outside:
const res = await cart.call({ type: 'checkout' })   // the reply, typed

If a process crashes, pending calls reject and readers retain the last value with stale: true. With restart: 'on-crash', it restarts from its original arguments and replays queued messages. See examples/form, and Error handling for every way a call can reject.

7. Share by name

lookup returns the process for a name and arguments, starting it when needed:

import { define, registry } from '@nonchalant/core'

const shop = registry({
  cart: define(cartProc),
  user: define(userQuery, { evict: 30_000 }),   // idle 30s after its last watcher → cleaned up
})
const cart = shop.lookup('cart')
const user = shop.lookup('user', { userId })

This provides shared dependencies without prop drilling and caches process instances by name and arguments. The registry counts watchers, evicts idle entries, and starts a fresh process on the next lookup. When the arguments come from user input, registry(defs, { maxEntries: 1_000 }) caps how many entries it keeps: past the cap, the least recently used unwatched ones are disposed. These lifecycle rules are covered by packages/core/test/registry.test.ts.

8. Use a remote registry

connect(transport) returns the same lookup interface, backed by a server. The shared-cart example makes the boundary visible:

const shop = registry({ cart: define(cartProc) })                    // in this tab
// const shop = connect<Shop>(webSocketTransport('ws://…:4321/'))    // on the server

The cart and view code do not change because they depend on the registry interface rather than a concrete location. Updates cross the wire as small patches (the same format used locally), remote reads stay fine-grained, losing the connection leaves readers on the last value with stale: true. After reconnecting, the client retrieves the state again and compares it with the retained value. Bindings for unchanged data are not notified.

The server setup is small (examples/shared-cart/server.ts):

import { define } from '@nonchalant/core'
import { serve } from '@nonchalant/host'

const host = await serve({ cart: define(cartProc) }, {
  port: 4321,
  allowedOrigins: ['https://shop.example'],
  authorize: async (request) => Boolean(await sessionFromRequest(request)),
})

The open default is convenient for the local example, not a production security policy. Origin checks protect browser handshakes; authorization decides who may connect; the scope option decides which processes each connection's lookups may reach. The gateway scope returns can also admit or refuse each message a client sends, and name a principal so one client's call ids cannot collide with another's; lookups are rate-limited per connection. Alternatively, processes can enforce record and operation access themselves. See Hosting safely.

Where to next

This page's source on GitHub