1. docs
  2. activities

Activities

The activity is loom's reactive primitive: a small pub/sub with an initial value, an optional transform, and subscription styles for content, attributes, and plain handlers. Everything reactive in loom — routing included — is built on it.

The activity Copy link

At its core, an activity is a pub/sub around a single value.

When creating a new activity, you provide an initial value — & optionally a transform and/or options. One or more effects may be queued within your component ecosystem for any given activity. Then, by hooking an activity update to some event, all subscribed effects will be called in order of "first-in, first-out".

API activity<V, I = V>(initialValue, transformOrOptions?, options?)

V is the stored value type; I is the update() input type, which only differs from V when a transform maps one to the other.

Inclusion import { activity } from '@loom-js/core';

Arguments

  • initialValue: V - The starting value. reset() returns to it, & it stays available as the frozen initialValue property, so the baseline can't drift.

  • transformOrOptions?: ActivityTransform<V, I> | ActivityOptions<V, I> - Either the transform function, or the options object when no transform is needed.

  • options?: ActivityOptions<V, I> - The options object, when the second argument is a transform.

Transforms (the async-data path) Copy link

A transform sits between update() & the stored value: every update(input) call routes through it, & only the transform's own update calls commit values. The initial value doesn't take this path — it's stored as-is at creation, untransformed; the transform first runs on the first dispatch. It receives one context object:

  • input: Readonly<I> - Whatever the caller passed to update() — may be a different type than the stored V. It is handed through by reference and typed read-only: core never copies or freezes it, so treat it as borrowed — read it, don't write it (the compiler enforces this).

  • update(next: V) - Commits a value; call it as many times as needed within the run (a newer dispatch retires the run under the default concurrency — see below). It deliberately shadows the activity's own update: inside a transform, updating is committing — dispatching the activity from within its own transform would loop infinitely.

  • value: V - The current value at the moment the update was dispatched — a shallow copy for plain objects & arrays (not frozen), so it is a safe base to build on. It is bound once per run: it does not move across awaits, and it does not reflect the run's own commits. (Under concurrency: 'serial' the read moves to the run's start, so each run sees its predecessor's committed result.)

  • signal: AbortSignal - Aborts when the run is retired — superseded by a newer dispatch (default concurrency) or timed out. Wire it into cancellable work (fetch(url, { signal })) so a retired run's request actually stops and settlement drains promptly. Ignoring it is always safe: a retired run's late commits are dropped regardless.

An async transform's returned promise is tracked by the settlement signal — settled(), the signal renderToString & hydrate gate on — which is what lets server renders & hydration swaps wait for activity data to land. This makes transforms the framework's idiomatic path for async data (see Server Rendering, Client Hydration & Dehydrated State):

ts Copy code
import { activity } from '@loom-js/core';
 
const page = activity<PageData | undefined, string>(
  undefined,
  async ({ input: slug, update }) => {
    update(await fetchPage(slug));
  }
);
 
// Callers pass the transform's input type — here, the slug string.
page.update('docs/intro');

A transform may commit more than once per run — each commit notifies effects, so intermediate states paint:

ts Copy code
import { activity } from '@loom-js/core';
 
type SearchState =
  | { status: 'loading' }
  | { status: 'ready'; results: Result[] };
 
const search = activity<SearchState, string>(
  { status: 'loading' },
  async ({ input: query, update }) => {
    // First commit: effects render the loading state immediately.
    update({ status: 'loading' });
    // Second commit: effects re-render with the data.
    update({ status: 'ready', results: await searchDocs(query) });
  }
);

Accumulating within a run seeds from value once, then builds locally — value is a dispatch-time snapshot, so re-reading it per commit would drop the run's earlier commits:

ts Copy code
const feed = activity<Result[], string>(
  [],
  async ({ input: query, update, value }) => {
    // Seed from the committed value at dispatch, accumulate locally.
    let results = [...value];
 
    for await (const batch of searchPages(query)) {
      results = [...results, ...batch];
      // Each commit paints everything gathered so far.
      update(results);
    }
  }
);

Transform concurrency Copy link

Async transforms can overlap — a second update() can dispatch before the first run's data lands. The concurrency option picks the semantics; the default extends the sync path's guarantee (the value reflects the last update()) to async:

  • 'latest' (default) — a newer update() retires an in-flight run: its signal aborts and its late commits are dropped, so an earlier dispatch resolving late can never overwrite newer data. Commits still flow freely within the current run (loading → data). Reach for it whenever a new dispatch makes the old one moot — fetch-and-display, search-as-you-type, per-navigation page data.

  • 'ordered' — every dispatch's transform starts immediately (parallel speed), but commits apply strictly in dispatch order: a run's commits are held until every earlier dispatch has settled and flushed, and nothing is dropped. Reach for it when runs are independent but arrival order matters — chart points, event streams, append-only feeds.

  • 'serial' — a dispatch's transform doesn't start until the prior run settles (rejection included), and each run's value sees its predecessor's committed result. Reach for it when runs build on each other — running totals, ledgers, anything accumulating through value. The cost is inherent queue latency: asking for order means waiting in line.

In every mode a rejected run releases its turn, and superseded runs stay settlement-tracked — settled() (and so renderToString / hydrate) waits for them to actually settle, which is why wiring signal matters: an aborted fetch settles immediately instead of holding the swap open. One boundary to know: the gate covers only the run's own update — writes a transform makes to other activities (a fan-out pipeline feeding siblings) are ordinary calls core cannot attribute to the run, so guard them the same way: check signal.aborted after each await before writing elsewhere.

The settlement signal Copy link

Every async transform run is tracked: the promise it returns registers with a per-window signal, and settled() — importable from core — resolves once nothing tracked is pending, confirmed by one macrotask of continued quiet, so chained work (a route page whose import then dispatches a fetch) is awaited to quiescence. That signal is what the framework's boot and render paths gate on:

  • renderToString waits on it before serializing, so route pages, lazy content, and activity data land in the markup (Server Rendering).

  • hydrate waits on it before its single swap (Client Hydration); settled() itself is the test await point (settled).

  • The router's deferred hash scroll fires once it resolves (Routing › Hash and anchor navigation).

  • lazyImport and createRoutes page imports are activities with transforms, so they are tracked for free (Lazy Imports).

The tracking boundary. Only work that passes through a transform counts: the thenable a transform returns, and every resource() awaited inside one (Dehydrated State › resource). Async work outside that path — a raw fetch in a watch callback, a setTimeout, a promise created in a life-cycle hook — is invisible to the signal. On the client, hand it to hydrate via ready; on the server, move it into a transform. The gate also covers only the run's own update: writes a transform makes to other activities after an await are ordinary calls, so guard them with signal.aborted (Transform concurrency, above).

Bounds. Superseded and retired runs stay tracked until they actually settle — wiring signal into cancellable work is what lets an aborted fetch settle immediately. A run that never resolves is bounded per activity by the timeout option (below); the waiters are bounded by their own maxWait (renderToString, hydrate), whose expiry warning enumerates the labelled activities still pending (Diagnostics › Naming subjects with label).

See also — Transforms · Transform concurrency (signal, timeout) · Lazy Imports · Routing › Hash and anchor navigation · Server Rendering › renderToString (maxWait) · Client Hydration › settled · Client Hydration › Semantics worth knowing (ready) · Dehydrated State › resource · Diagnostics › Naming subjects with label.

Transforms decide how a value changes; the options tune when a change counts.

Options Copy link

ActivityOptions:

  • concurrency?: 'latest' | 'ordered' | 'serial' - [Default: 'latest'] Dispatch semantics for overlapping async transform runs — see Transform concurrency, above.

  • deep?: boolean - [Default: false] Compare plain objects property-by-property & arrays element-by-element (a shallow diff) instead of by reference, so a same-content update doesn't cascade to subscribed effects.

  • force?: boolean - [Default: false] Treat every update as a change, skipping comparison entirely.

  • label?: string - [Default: unset] Names the activity in diagnostics — narration lines, dropped-commit & timeout notices, & the pending enumeration of bounded settlement warnings all show ⟨label⟩. Purely diagnostic, never behavioral. Unlabeled activities fall back to a stable generated tag (activity#3) so lines stay distinguishable — see Diagnostics.

  • timeout?: number - [Default: unset — unbounded] Upper bound (ms) per transform run. On expiry the run is retired exactly as supersession retires one — signal aborted, later commits dropped, any 'serial' queue or 'ordered' turn released — and it counts as settled for the settlement signal even if its promise never resolves, so a hung transform can't block a queue or pin settled(). Expiry warns on the console. With debug narration on (setDebug(true, { activity: true })), a long-pending run on an activity that sets no timeout is flagged instead — pointed at, never bounded for you.

  • transform?: ActivityTransform<V, I> - The transform, for when options ride in the second argument.

The returned interface Copy link

Properties

  • initialValue - The value the activity started with. Frozen (Object.freeze) when it's an object, so the reset baseline can't be mutated.

Methods

effect

effect(({ value }) => TemplateTagValue)

  • An effect is called at least once per use, when it's first introduced during the component render process. Additionally, it's called once per activity update.

  • value - the initial activity value, or the new value on updates.

  • The action returns what renders in the effect's slot — idiomatically a called component (a ContextFunction), though any interpolatable TemplateTagValue is accepted.

bind

bind(select?)

  • Creates a reactive attribute binding for template attr slots — the bound attribute applies select of the current value immediately and stays in sync with every update, without re-rendering the component. Cleanup is automatic: the binding is disposed when a re-render replaces the slot's value and on unmount.

  • select - projects the activity value to the attribute value; defaults to identity.

  • Prefer bind over an effect boundary when only an attribute depends on the activity; prefer effect when content or structure changes.

reset

reset()

  • Returns the activity to its starting value (subject to the same change comparison as any update). The transform is bypassed, but the reset dispatches like any other run — under the default concurrency it retires an in-flight transform run, so stale data can't land on top of a reset.

update

update(input, forceUpdate?)

  • Calling this method will trigger all subscribed effects from the related activity, passing the new value to each effect. With a transform, input is handed to the transform (which commits through its own update); without one, input is stored directly.

  • forceUpdate - [Default: the force option] Treat this one update as a change regardless of comparison.

value

value()

  • A getter which returns the current value, initially initialValue. Plain objects & arrays come back as a shallow copy, so mutating the returned value can't defeat change detection — commit changes through update().

watch

watch(action)

  • Subscribes a caller-managed handler: action runs immediately with the current value, then on every update.

  • Returns an Unsubscriber — cleanup is the caller's responsibility (e.g. pair it with onUnmounted), unlike effect (context-managed) and bind (template-managed).

Component-scoped state Copy link

An activity created inside the render function is scoped to that component — each rendered instance owns its own state:

ts Copy code
import { activity, component } from '@loom-js/core';
 
export const Disclosure = component((html, { children, label }) => {
  const isOpen = activity(false);
 
  return html`
    <section>
      <button
        $click=${() => isOpen.update(!isOpen.value())}
        aria-expanded=${isOpen.bind((open) => String(open))}
        type="button"
      >
        ${label}
      </button>
      ${isOpen.effect(({ value }) => (value ? children : undefined))}
    </section>
  `;
});

Choose your scope. Internal reactivity — the component's own effects and binds — re-renders content without re-running the render function, so the plain local activity above persists through it. A parent re-rendering the component re-runs the render function, which recreates that activity and resets it. When instance state must survive parent-driven re-renders, create it through the own utility prop: on the instance's first render it invokes the factory and caches the result; every re-render returns the cached value.

ts Copy code
import { activity, component } from '@loom-js/core';
 
export const Disclosure = component((html, { children, label, own }) => {
  // Survives parent re-renders — same activity instance every render.
  const isOpen = own(() => activity(false));
 
  return html`
    <section>
      <button
        $click=${() => isOpen.update(!isOpen.value())}
        aria-expanded=${isOpen.bind((open) => String(open))}
        type="button"
      >
        ${label}
      </button>
      ${isOpen.effect(({ value }) => (value ? children : undefined))}
    </section>
  `;
});

own replays cached values by call order — the same rule createRef already follows: call it unconditionally, in the same order, every render (a mismatched call count gets a debug-lane warning). Values live exactly as long as the component instance — released on unmount, isolated per instance and per server render — and disposing anything the factory allocated stays yours, paired with onUnmounted. State shared between instances still belongs at module scope.

Examples Copy link

Attribute binding

ts Copy code
import { activity, component } from '@loom-js/core';
 
const isOpen = activity(false);
 
const Panel = component(
  (html) => html`
    <section
      class=${isOpen.bind((open) => (open ? 'panel _open' : 'panel'))}
    >
      Content is untouched when the class updates.
    </section>
  `
);

Counter

ts Copy code
import { activity } from '@loom-js/core';
 
const initialValue = 0;
export const buttonClickActivity = activity(initialValue);
console.log(buttonClickActivity.initialValue); /* => 0 */
 
console.log(buttonClickActivity.value()); /* => 0 */
buttonClickActivity.update(1);
console.log(buttonClickActivity.initialValue); /* => 0 */
console.log(buttonClickActivity.value()); /* => 1 */

The same counter driving a component through effect (see Components for the template side):

ts Copy code
import { activity, component } from '@loom-js/core';
 
// Initialize a new activity with an initial value.
export const buttonClickActivity = activity(0);
console.log(buttonClickActivity.initialValue); // => 0
 
export interface BlueLabelProps {
  label: string;
}
 
// We'll update this label, reactively, as an effect of the activity.
export const BlueLabel = component<BlueLabelProps>(
  (html, { label }) => html`
    <span class="label blue">${label}</span>
  `
);
 
export const Button = component((html) => {
  const { effect, update, value } = buttonClickActivity;
  const onClick = () => {
    update(value() + 1);
    console.log(value()); // increments by 1 for every button click
  };
 
  // The effect is run immediately on first render and runs every time thereafter when the related activity is updated.
  // The effect returns what renders in its slot - idiomatically the output of a component (a `ContextFunction`).
  // `value` holds the current value of the activity.
  return html`
    <button $click="${onClick}" type="button">
      ${effect(({ value }) =>
        BlueLabel({ label: `Clicked count: ${value}` })
      )}
    </button>
  `;
});

Note the component is called inside the effect — an effect's return is a value position, the canonical home of the functional form: see when each form fits.

The next two topics are built directly on this primitive: Routing — its location and route layers are activities over the History API — and Lazy Imports, a dynamic import() wrapped in an activity.