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
doneconst 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
doneconst 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
doneconst 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
doneconst 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
doneconst 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
donealert("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
doneconst 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
doneconst 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
doneconst 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
doneconst 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]}.