Edit on GitHub

Reactivity

When you write state count = 0 in a .jslop file, the compiler turns it into a cell — a reactive value provided by @jslop/runtime. Most of the time you don’t need to think about this: assign, read, done.

This page is for when you do. It documents the primitives @jslop/runtime exposes — useful for custom helpers, JS files you import into your components, or when you want a derived value or an ad-hoc effect.

The primitives

import { cell, derived, effect, batch, untrack } from "@jslop/runtime";
Primitive What it is
cell(initial) A writable reactive value.
derived(fn) A read-only value computed from cells, cached, auto-updated.
effect(fn) A subscription that re-runs whenever any cell it read changes.
batch(fn) Defers notifications until fn returns; each subscriber runs once.
untrack(fn) Reads cells without subscribing to them.

You rarely need to call these directly inside a component. The compiler emits cell(...) for every state/prop and effect(...) for every reactive {expr} in the view. Plain let declarations are not cells.

cell<T>(initial: T): Cell<T>

const count = cell(0);

count.get();     // 0     — tracks if called inside an effect/derived
count.peek();    // 0     — never tracks
count.set(1);
count.update(n => n + 1);

set is a no-op when the new value is Object.is-equal to the old one, so trivial reassignments don’t trigger updates.

Tip

Use peek() inside event handlers when you want the current value but don’t want to add a subscription. The compiler already does this for compound assignments like count++.

derived<T>(fn: () => T): Derived<T>

const count = cell(0);
const doubled = derived(() => count.get() * 2);

doubled.get();   // 0
count.set(5);
doubled.get();   // 10

derived is just cell + effect under the hood — the inner function re-runs whenever its dependencies change, and the result is cached.

Tip

The DSL has a derived name = expr keyword for use inside components — see Components → derived. Identifiers on the right-hand side that match a state/prop/derived are rewritten to .get() calls, so the result re-runs only when its inputs change. Reach for the runtime derived(() => ...) form when you’re outside a component body (JS helpers, ad-hoc effects).

effect(fn): () => void

const q = cell("");

const dispose = effect(() => {
  console.log("query is", q.get());
  return () => console.log("cleaning up previous run");
});

q.set("hello");   // logs cleanup, then "query is hello"
dispose();        // unsubscribes

fn may return a cleanup function. It runs before the next re-execution and on disposal.

batch(fn): void

const a = cell(0);
const b = cell(0);

effect(() => console.log(a.get() + b.get()));

batch(() => {
  a.set(1);
  b.set(2);
});
// effect runs once with 3, not twice with 1 then 3

Use this when one user action mutates several cells and you want exactly one render.

untrack(fn): T

const a = cell(1);
const b = cell(10);

const onlyTracksA = derived(() => a.get() + untrack(() => b.get()));

onlyTracksA updates when a changes but not when b changes.

isReactive(v)

Type guard the compiler uses for prop forwarding:

const c = cell(0);
isReactive(c);       // true
isReactive(42);      // false

If a parent passes a cell into <Child x={someCell} />, the child sees the same cell and writes flow back. If it passes a plain value, the child wraps it locally.

Scopes

Effects can be grouped into scopes so a chunk of work — typically one {#if} branch or one {#each} item — can be torn down as a unit.

import { createScope, runInScope, disposeScope, onCleanup, effect } from "@jslop/runtime";

const scope = createScope();
runInScope(scope, () => {
  effect(() => { /* ... */ });
  onCleanup(() => clearInterval(handle));
});

disposeScope(scope);  // cleans up the effect AND runs the onCleanup
  • createScope(parent?) — returns a new scope. Defaults to the current scope as parent so disposing the parent cascades to children.
  • runInScope(scope, fn) — runs fn with scope as the current scope; effect() calls inside register their disposer with it.
  • disposeScope(scope) — disposes child scopes recursively, then runs cleanups in LIFO order. Idempotent.
  • onCleanup(fn) — registers a non-effect cleanup with the current scope (timers, abort controllers, third-party subscriptions).

effect() snapshots the current scope at creation and restores it on every re-run, so a cell.set triggered from a foreign scope still parents new child scopes correctly under the effect’s owner.

@jslop/client uses this internally: boot opens a root scope per mounted component, {#if} opens a fresh scope per branch swap, {#each} opens one scope per list item. Most app code doesn’t need to call this API directly, but it’s available for ad-hoc subtrees (e.g. an imperative DOM mount).

How the compiler uses these

For a component like:

component Counter {
  state count = 0

  function inc() {
    count++
  }

  view {
    <button onclick={inc}>{count}</button>
  }
}

The compiler emits (simplified):

import { cell, isReactive } from "@jslop/runtime";

export const __jslop_component = {
  name: "Counter",
  create(props = {}) {
    const count = cell(0);
    function inc() {
      count.set(count.peek() + 1);
    }
    function buildView() {
      return {
        kind: "element",
        tag: "button",
        attrs: {},
        events: { click: inc },
        children: [
          { kind: "bind", get: () => String(count.get()) },
        ],
      };
    }
    return { actions: { inc }, buildView, serializeState, restoreState, children: [] };
  },
};

The client walks buildView() once to materialize DOM, wrapping each kind: "bind" in an effect so it updates fine-grained when its source cells change.

See Internals: architecture for the full pipeline.

Last updated: May 15, 2026