Skip to main content

Signals

The runtime's only state primitive. About sixty lines, written rather than imported, because the compiler needs to recognise signals by identity at build time.

signal

done
const count = signal(0);
const name = signal("ada");
const todos = signal<string[]>([]);

signal(initial) returns a Signal<T>, which is typed as T & Ops<T> — it is its value, plus a few methods. That intersection is what makes a bare read type-check.

Literal types are widened: signal("/") is a Signal<string>, not a Signal<"/">, so later writes are not rejected.

Reading

The read is the identifier. No .value.

const count = signal(0);

const doubled = computed(() => count * 2);
const isBig = computed(() => count > 5);
const isThree = computed(() => count === 3);
const label = computed(() => `count is ${count}`);

All four work, including ===, which is the one people expect to be broken. A build-time rewrite turns count === 3 into $(count) === 3.

Writing

const count = signal(0);

count.set(5);
count.set((n) => n + 1);

One method, taking a value or a function of the previous value. The ambiguity is a signal that holds a function, which is rare enough to document rather than design around.

Writing an equal value is a no-op — equality is Object.is, so subscribers do not run.

subscribe

const count = signal(0);
const stop = count.subscribe(() => {
/* ... */
});
stop();

Returns an unsubscribe function. You will rarely call this: the compiler wires bindings for you.

computed

done
const todos = signal([{ done: false }]);
const remaining = computed(() => todos.filter((t) => !t.done).length);

Derived, lazy, and cached. It recomputes on the next read after any dependency changes, so a computed nobody reads costs nothing.

Dependencies are captured automatically. There is no dependency array, and there is nothing to keep in sync.

A computed is a signal to everything downstream — it can be interpolated, passed to cn, or read inside another computed.

:::note Where a computed can live

A computed() created inside a component has nowhere to go once components are erased: the generated module imports every cell by name, and an anonymous one has no name. Declare derived state in a module beside the signal it derives from.

Component-local state is different, and does work — see component-local state. :::

batch

done
const first = signal("");
const last = signal("");

batch(() => {
first.set("Ada");
last.set("Lovelace");
});

Groups writes so subscribers run once at the end rather than once per write.

Invalidation still propagates immediately inside a batch — only the effects are deferred. That distinction is what stops a subscriber of both a signal and a computed derived from it from running twice.

isSignal

done
const count = signal(0);
isSignal(count); // true
isSignal(0); // false

A brand check. Mostly used by framework code and by anything writing a helper that takes "a signal or a plain value".

source

done
const config = source<Config>(
(set) => {
const watcher = fs.watch("config.json", async () => set(await readConfig()));
return () => watcher.close();
},
readConfigSync(),
);

A signal fed from outside the process. The first argument is "how to subscribe" — a function that receives set and returns an unsubscribe — and the second is the initial value. The subscribe runs once at launch (never while the compiler imports the module), and the unsubscribe runs on window close.

What the subscribe returns decides the shape. Return an unsubscribe and it is a callback source that needs nothing. Return an Effect Stream and dziry runs it with Stream.runForEach, importing effect lazily — see Effect.

export const todos = source<Todo[]>(() => liveTodos(), []);

A zero-argument () => Stream is assignable to the one-argument subscribe because a function may ignore arguments it does not declare.

alert

done
alert("Saved.");
alert("Could not reach the server.", { level: "error", title: "Offline" });

The platform's own modal message box — a Win32 task dialog, an NSAlert, the GTK box. SDL_ShowSimpleMessageBox behind an FFI call, so it is not something dziry draws; a dialog drawn by the framework would be the one part of your app that does not look like the system.

level is an AlertLevel"info" (the default), "warning" or "error" — and title defaults to your window's own title. The window stops repainting while the box is up, which is what a modal is.

The box shows the frame that caused it. The call is queued until the app thread's next commit and the engine paints once more before it blocks, so an alert raised from a handler that also changed the page shows the changed page behind it. Without that it did the opposite, in the case that matters most: a form listing every complaint inside the dialog with none of them visible behind it, because the alert is raised inside the same batch() that wrote them and a batch defers its subscribers to the end.

Import it. Bun defines a global alert() that writes to stdout and waits for Enter on stdin, so forgetting the import hangs your app thread on a terminal nobody is watching.

With no window there is nobody to notify, so it prints to stderr instead. That is what makes it safe in a handler you also screenshot.

:::note No answer to wait for

confirm() and prompt() are not built. The dialog runs on the engine thread — SDL will only show one from the thread that initialised video — so a call that waited for an answer would have the app thread block on the thread the dialog is already blocking. :::

$

const count = signal(0);
$(count); // 0
$(41 + 1); // 42

Unwraps a signal and passes everything else straight through.

You do not normally write this — the compiler emits it. It is exported because the rewrite puts it in your module's compiled output, and because framework code and un-rewritten modules occasionally need it by hand.

The whole reactive rewrite rests on $ deciding at run time. The transform rewrites every identifier read it sees without knowing which are signals, so over-rewriting is safe rather than merely tolerable: $(t) inside todos.filter((t) => ...) returns t, because that is the binding in scope. The cost of a read that was never a signal is one predicate.

Types

import type { MapOptions, ReadonlySignal, Signal } from "dziry";

ReadonlySignal<T> is T & Ops<T> — readable, subscribable, mappable, but no .set. computed returns one.

Signal<T> adds .set. signal returns one.

MapOptions<Item> is the second argument to .map — see Markup.

effect

done
const count = signal(0);
const label = signal("");

effect(() => {
label.set(`count is ${count}`);
});

Runs now, and again whenever anything it read changes. Dependencies are captured the same way a computed captures them — no array to declare, and the captured set is replaced on every run, so a branch that stopped reading a signal stops being woken by it.

Return a function and it is the cleanup — run before each re-run and at disposal:

effect(() => {
const id = setInterval(() => tick.set(Date.now()), 1000);
return () => clearInterval(id);
});

effect returns a disposer. Most callers should not keep it — an effect created at module scope lives for the window's lifetime — but it exists for the exceptions:

const stop = effect(() => { /* ... */ });
stop();

untrack

done
const a = signal(1);
const b = signal(100);

const total = computed(() => a + untrack(() => b));

Reads inside untrack do not become dependencies of the enclosing computation. Here total recomputes when a changes and not when b does — but the recompute reads the current b.

peek

done
const count = signal(0);
count.peek(); // the value, without subscribing whatever is running

The single-read form of untrack. Inside a computed or effect, count.peek() reads without making count a dependency. Outside one it is just the value.

createScope

done
const scope = createScope();

scope.run(() => {
effect(() => { /* ... */ });
});

scope.dispose(); // every effect created inside run() is disposed

An ownership boundary for effects. Disposing a scope disposes everything it owns, transitively — a scope created inside another scope's run belongs to it. This is what lets a teardown say "everything this part of the app created" without tracking handles by hand.

scope.own(fn) adds an arbitrary disposer; run on a disposed scope throws, and own into one runs the disposer immediately rather than losing it.

resource — pull-based async data

done
import { resource } from "dziry";

async function fetchStats(): Promise<{ users: number }> {
return { users: 42 };
}

export const stats = resource(async () => fetchStats(), { users: 0 });

source is push, from outside the process; resource is pull: it runs its fetcher once at launch — never at import, so the compiler touching your modules fetches nothing — and the value that comes back is the data signal. {stats} binds it like any signal, stats.map(...) compiles a list from it. Three members ride on it: stats.status ("pending" | "ready" | "stale" | "error", a signal), stats.error, and stats.refetch().

refetch() sets "stale", never "pending" — the shown data stays up while newer is fetched, so revalidating cannot flash a fallback. Only the first run pends.

<Suspense fallback={...}> shows a fallback exactly while a resource read under it is pending. Both trees are compiled into the window; the switch is a byte write, the same one navigation makes. A read hidden inside a computed is invisible to the boundary's collection — name it explicitly with on={[stats]}.