Skip to content

State and services

A cell holds what a component owns. Everything else has to live somewhere a component can reach: the value two screens share, the value that outlives the screen that made it, the value something on another thread is authoritative about.

There are two places for it, and one question picks between them.

What the value isWhere it goes
One component's owninternalState in the body
A component's and its children'sThe same cell, passed down as a prop
A whole subtree's, without prop drillingAn environment value, the way theme is provided
Shared between screens, and never leaving the threadA service: a class the runtime constructs once and hands out
Owned elsewhere, or outliving the screenA channel: a declared barrier with data on the other side of it

The last two rows are this page. The test between them is not how important the value is; it is whether it crosses a thread.

Two panels, no props between them. Pressing + sends a command to the basket and the summary is redrawn by the patch that comes back. Clicking a name writes a service, and the summary reads the same object.

A service is a class the runtime hands out

Register it once, where the app is created, and inject it wherever it is wanted:

ts
renderRoot(AppRoot).useService(Highlight);
tsx
/**
 * Which line the reader is looking at.
 *
 * View state: it never leaves this thread, nothing outside the screen
 * cares about it, and it does not survive a reload. So it is a plain
 * class the runtime constructs once and hands to whoever injects it,
 * and `internalState` outside a component is only a `BehaviorSubject`
 * with a `.value` setter.
 */
export class Highlight {
  readonly id = internalState<string | null>(null);
}

internalState is not tied to a component. It is a BehaviorSubject with a .value setter, so a service can hold one and a binding can read it directly, which is how the runtime's own services expose state.

A component asks for the class and gets the instance:

ts
const highlight = ctx.inject(Highlight);

A class component uses the decorator form instead, which is the same registry: @Inject(Highlight) highlight!: Highlight.

The runtime registers its own services the same way, so ctx.inject reaches overlays, focus, find, media, animation, the router, and ShellService for the clipboard, URLs and the appearance. Injecting a class nobody registered throws while the component is being built, naming the class and listing every service that is registered.

When a plain service is enough

A service is right when all of these hold:

  • The value never leaves the render thread.
  • Losing it on a reload is correct rather than a bug.
  • Nothing outside the running UI is authoritative about it.

Which selection is active, which overlay is open, which panel is expanded, a draft the reader has not committed: view state. Putting it in a service costs nothing and reads as an ordinary object.

A change to a service's own code does not survive a hot replacement either. The instance does, so the value it holds is kept, but its methods are still the old code. Hot module replacement says what to hand over and what stays stale.

A channel is the barrier, declared once

When the state crosses a thread, the two sides cannot share an object, and the thing they share instead is a token: a name, a shape, and the value every key holds before anything has been sent.

tsx
/** One line of the basket, already shaped for the screen. */
export interface BasketLine {
  readonly id: string;
  readonly name: string;
  readonly quantity: number;
}

/** What the screen may read. Every key holds plain data. */
export interface BasketView {
  readonly lines: readonly BasketLine[];
  readonly total: string;
}

/** What the screen may ask for. No return values: the effect comes back as a patch. */
export interface BasketCommands {
  add(id: string): void;
  remove(id: string): void;
}

/**
 * The barrier, declared once and imported by both sides.
 *
 * A token is a name, a shape and an initial value, and no
 * implementation at all, so the module holding it has nothing in it to
 * bundle. The initial value is what the screen shows before the first
 * patch, which is why a view key is never `undefined`.
 */
export const Basket = channel<BasketView, BasketCommands>('docs-basket', { lines: [], total: '$0.00' });

The module holding it has no implementation in it, which is the point. Both threads import the token; only one of them imports the code behind it, so an app's api client, its repository and its domain models never reach the render worker.

The side that owns the data

Whatever produces the values is the application's own business. The framework sees observables of plain data and nothing else:

tsx
/**
 * The application behind the channel: plain classes and plain RxJS,
 * with no framework import and nothing that knows a screen exists.
 *
 * Formatting `total` here rather than in the view is deliberate. The
 * layer holding the numbers is the last one with the whole value in
 * hand, and a string costs one small patch where a recomputed number
 * would cost the same patch plus a `map` on every binding that reads
 * it.
 */
export class BasketModel {
  private readonly counts = new BehaviorSubject<Readonly<Record<string, number>>>({ brush: 1 });

  readonly lines: Observable<readonly BasketLine[]> = this.counts.pipe(
    map(counts => CATALOGUE.map(item => ({ id: item.id, name: item.name, quantity: counts[item.id] ?? 0 })))
  );

  readonly total: Observable<string> = this.counts.pipe(
    map(counts => `$${CATALOGUE.reduce((sum, item) => sum + item.price * (counts[item.id] ?? 0), 0).toFixed(2)}`)
  );

  add(id: string): void {
    const counts = this.counts.value;
    this.counts.next({ ...counts, [id]: (counts[id] ?? 0) + 1 });
  }

  remove(id: string): void {
    const counts = this.counts.value;
    this.counts.next({ ...counts, [id]: Math.max(0, (counts[id] ?? 0) - 1) });
  }
}

One Observable per view key, one handler per command, and that object is the entire seam:

tsx
/**
 * What the owning side supplies: one Observable per view key, one
 * handler per command. Registering it is the only place the two halves
 * of this file meet.
 */
export function basketSource() {
  const basket = new BasketModel();
  return {
    view: { lines: basket.lines, total: basket.total },
    commands: {
      add: (id: string) => basket.add(id),
      remove: (id: string) => basket.remove(id)
    }
  };
}

Above that line there is no framework import: plain classes and plain RxJS, testable with bare vitest. The framework defines the barrier and has no opinion at all about what is behind it. An api layer, a repository, a domain model and a view model is one arrangement; a single subject is another; nothing here can tell the difference.

The side that reads it

ctx.channel(token) returns the replica, which runs none of the application's logic. It holds the latest value of each view key and forwards commands:

tsx
/**
 * The second screen. It reads the same two view keys and the same
 * service as the first, and knows where neither of them lives.
 *
 * `basket.view.total` is an `InputCell`, so it binds like any prop.
 * `items` is derived from `lines` and is therefore not stored
 * anywhere, and `looking` is one expression over a view key and a
 * service.
 */
function Summary(_inputs: Inputs<{}>, ctx: ComponentContext) {
  const basket = ctx.channel(Basket);
  const highlight = ctx.inject(Highlight);

  const items = basket.view.lines.pipe(map(lines => lines.reduce((sum, line) => sum + line.quantity, 0)));
  const looking = combineLatest([basket.view.lines, highlight.id]).pipe(
    map(([lines, id]) => lines.find(line => line.id === id)?.name ?? 'nothing yet')
  );

  return (
    <column
      gap={8}
      width={170}
      padding={14}
      borderRadius={10}
      borderWidth={1}
      borderColor="border"
      backgroundColor="surface">
      <text text="Summary" fontSize={13} fontWeight={600} color="text" />
      <text text={basket.view.total} fontSize={22} fontWeight={600} color="text" />
      <text text={items.pipe(map(count => `${count} in the basket`))} fontSize={12} color="textMuted" />
      <text text={looking.pipe(map(name => `Looking at ${name}`))} fontSize={12} color="textMuted" />
    </column>
  );
}

Every view key is an InputCell, exactly like a prop. A component reads it, binds it, and cannot write it, and it makes no difference to the component whether the value came from a parent or from across a barrier. The class form is @Channel(Basket) basket!: ChannelReplica<BasketView, BasketCommands>.

To change something, send a command:

tsx
/**
 * One line, holding both kinds of state at once.
 *
 * The name button writes the service directly, because the value stays
 * here. The two step buttons send a command, because the quantity
 * belongs to whoever owns the basket.
 */
function Line(inputs: Inputs<{ line: BasketLine }>, ctx: ComponentContext) {
  const basket = ctx.channel(Basket);
  const highlight = ctx.inject(Highlight);

  const background = combineLatest([inputs.line, highlight.id]).pipe(
    map(([line, id]) => (line.id === id ? 'controlBackground' : 'transparent'))
  );

  return (
    <row gap={8} y="center">
      <button
        label={inputs.line.pipe(map(line => `Look at ${line.name}`))}
        onClick={() => (highlight.id.value = inputs.line.value.id)}
        flex={1}
        padding={6}
        borderRadius={6}
        backgroundColor={background}
        cursor="pointer"
        modifiers={[HOVER_CONTROL]}>
        <text text={inputs.line.pipe(map(line => line.name))} fontSize={13} color="text" />
      </button>
      <text
        text={inputs.line.pipe(map(line => String(line.quantity)))}
        fontSize={13}
        color="textMuted"
        width={18}
        textAlign="center"
      />
      <Step
        label={inputs.line.pipe(map(line => `One fewer ${line.name}`))}
        glyph="−"
        onPress={() => basket.send.remove(inputs.line.value.id)}
      />
      <Step
        label={inputs.line.pipe(map(line => `One more ${line.name}`))}
        glyph="+"
        onPress={() => basket.send.add(inputs.line.value.id)}
      />
    </row>
  );
}

Registering it

Where the data lives is decided at registration, in one place, by the option passed:

RegistrationWhere the data lives
.useChannel(Basket)The application worker the shell spawned
.useChannel(Basket, { worker })A worker of this channel's own
.useChannel(Basket, { source })This thread, over a MessageChannel to itself
ts
/**
 * The render worker behind `<LiveExample id="state" />`.
 *
 * One service and one channel, which is the whole taxonomy in two
 * lines. The channel is registered with a `source`, so its data lives
 * on this thread; naming a `worker` instead moves it, and nothing in
 * `StateExample.tsx` changes.
 */
renderRoot(exampleRoot(createComponent(StateScreen, {})))
  .useService(Highlight)
  .useChannel(Basket, { source: basketSource() });

A source registration still crosses a real port, and is diffed, patched and plain-data checked exactly like a worker's. That is what makes the choice a one-line change later: the code above never learns which it got.

The first row needs an application worker to exist. A single-thread app has none, and so does a worker-hosted app started without appLogicWorker, and a channel registered there with neither a worker nor a source throws at startup saying which of the three to pass.

What a command costs, and what it does not do

A command is fire and forget. It has at most one argument, that argument is structured-cloned onto the owning thread, and it returns nothing. There is no synchronous answer to be had across a thread, so the effect arrives as a patch on the view keys it changed.

The spec beside this example measures both halves of that. After a click on +, a frame drawn in the same turn still shows the old total, because the message has not been delivered yet. The new total appears once the round trip is done. A click on a name, which writes a service, is on screen in the very next frame.

So a value written and read back in the same handler has to be a cell or a service. Nothing across a barrier answers that fast, and a command that looks like it did would be lying about the thread model.

Limits worth knowing before the first channel

  • Only plain data crosses. Primitives, arrays and plain objects. A Date, a Map, a Set or a class instance compares by reference, so it would report a change on every update and rebuild the subtree bound to it forever. The first value of each key is checked and the channel reports an error naming the key and the path inside it. Flatten it in the layer that owns it.
  • Declare keys finely. A key holding a large array is re-diffed whenever it changes. The differ trims a common prefix and suffix, so an append or an edit in place stays small, while a re-sort degrades to replacing the middle. Two keys that change for different reasons are two keys.
  • There is no undefined window, and no implied loading state. The token's initial value is what the screen draws until the first patch, so a channel that is still loading says so in its own shape, with a status key, rather than leaving the view to infer it from an absence.
  • A service is per runtime and per thread. There is exactly one instance, it is reachable only from components in that runtime, and it goes away with the app. Nothing about it is shared with another thread.
  • Channels are registered before the runtime starts. Registering one afterwards throws, and so does asking for a channel that was never registered.

This page's example registers its channel with a source, so its data lives in the render worker beside the components, and everything above was measured in that configuration. The two-worker arrangement changes the registration line and nothing else in these files, which is a property of the API rather than something this page measured.

Next

The action log records every command and every patch crossing a channel, which is the fastest way to see the round trip above as a timeline.