Skip to main content

Reactivity

Five things: signal, computed, .set, .map, cn. And a read is just the name.

A read is the identifier

const count = signal(0);

const doubled = computed(() => count * 2);
const isBig = computed(() => count > 5);
const isThree = computed(() => count === 3);
const parity = computed(() => (count % 2 === 0 ? "even" : "odd"));
const shout = computed(() => `count is ${count}!`);

Every one of these was broken before the build-time rewrite landed. === compared a signal object to a number and was false for ever; a ternary saw an object and was always truthy; a template literal printed [object Object].

Now count * 2 is compiled to $(count) * 2. $ unwraps a signal and passes everything else through, and it decides at run time — which is why the transform needs no type information, no module graph and no scope analysis. Over-rewriting is harmless: $(t) where t is a plain parameter returns t.

The type side matches. Signal<T> is T & Ops<T>, so count * 2 also type-checks.

:::note Where the rewrite runs

On your code, under windows/. Not on the framework's own modules under src/ — that is where $ is defined. So you will see .value inside the framework and should never need it in your own code. :::

Writes

const count = signal(0);

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

One method, taking a value or a function of the previous one. Reads work in handlers too, so count.set(count + 1) is fine.

Method calls on a signal resolve correctly without you thinking about it: .set, .subscribe, .value and .peek belong to the signal, while .filter, .length, .trim and the rest belong to its value.

const todos = signal<{ done: boolean }[]>([]);
const draft = signal("");

todos.filter((t) => !t.done); // the array's filter
draft.trim(); // the string's trim
todos.set([]); // the signal's set

In markup

<div>{count}</div> // the signal itself, by identity
<div>{doubled}</div> // a computed is a signal too
<div>{`at ${count}`}</div> // an expression — see below

There is one rule worth knowing. A brace holding a lone signal is resolved by identity. A brace holding an expression is compiled into a cell, and a cell reaches the generated module as text — so it can only name module exports.

That means a local cannot be written into an inline expression. This is a build error naming the export you should have used:

{`at ${router.path}`}

while this compiles fine, by identity:

{router.path}

If you want the interpolated version, declare it as a computed in the module and interpolate that.

Where state lives

Signals and handlers are normally module-level exports, so the compiler can name them. {draft} in JSX passes the signal object itself, and the compiler reverse-maps it to the export it came from.

windows/main/state.ts
import { computed, signal } from "dziry";

export const draft = signal("");
export const todos = signal<Todo[]>([]);

export const remaining = computed(() => todos.filter((t) => !t.done).length);

export function addTodo(): void {
const title = draft.trim();
if (title === "") return;
todos.set([...todos, { id: nextId++, title, done: false }]);
draft.set("");
}

Component-local state

State that belongs to one component can be declared where it is used:

function LocalCounter() {
const n = signal(0);

return (
<div>
<div>{n}</div>
<button onClick={() => n.set(n - 1)}></button>
<button onClick={() => n.set(n + 1)}>+</button>
</div>
);
}

This works even though the artifact can only hold names. The compiler registers the signal and declares const locals = [signal(0)] in the generated module, then rewrites the inline arrow with n substituted for its registry slot.

There is no render and no unmount here — the component body runs once, at build time — so "created once" comes for free. The only missing piece was a name, and the registry supplies it.

Two consequences follow. It needs no state module, and it cannot be shared: this component's state is its own. Rendering the component twice is not a thing that happens, because there is no run-time rendering.

Derived state must be named

A computed() created inside a component has nowhere to live: the generated module imports every cell by name, and an anonymous one has no name. Declare it beside the signal it derives from.

// In the window's module, not in a component.
export const onNewProduct = computed(() => route === "products/new");

This is a constraint the compiler imposes, not a style preference.

Batching

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

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

Subscribers run once at the end rather than once per write. A single event handler already produces one repaint; batch is for when you are making several related writes from elsewhere.

What this costs

A change to a signal is not a render. It is a small number of integer writes into the style or text tables, followed by a repaint of what changed. There is no tree to walk, no diff, and no selector to re-match.

An idle frame costs nothing at all.