Observables and computeds

One cell of state, values derived from it, and effects that react to both.

The model in ten seconds

An observable holds one value, read and written through .value. A computed is a function whose inputs are found at runtime: it reads what it reads, and recalculates only when one of those things changes. An effect is the same machinery pointed at a side effect. Every binding on a page is an effect.

0

Doubled
0
Parity
even
Effect runs
1

Nothing else on this page re-rendered. Three bindings read count; three bindings ran.

apps/basics.js - the counterview whole file
/**
 * Counter - `observable`, `computed`, `effect`, and what "fine-grained" means.
 *
 * Three bindings read `count`. Pressing +1 re-runs those three and nothing
 * else on the page, because each binding owns its own effect and each effect
 * knows exactly what it read.
 */
function counter() {
    const count = observable(0);
    const runs = observable(0);

    // A computed works out its own inputs at runtime. Neither of these is told
    // that it depends on `count`; both discover it by reading it.
    const doubled = computed(() => count.value * 2);
    const parity = computed(() => (count.value % 2 === 0 ? 'even' : 'odd'));

    // An effect is the same machinery pointed at a side effect. `peek()` reads
    // without subscribing - reading `runs.value` here would make this effect
    // depend on its own write and spin forever.
    effect(() => {
        count.value;
        runs.value = runs.peek() + 1;
    });

    return {
        count, doubled, parity, runs,
        step(by) { count.value += by; },
        reset() { count.value = 0; }
    };
}

doubled is never told it depends on count - it finds out by reading it. Writes are batched: however many you make in a row, the graph settles once, on the next microtask.

A computed you can write to

A computed is read-only unless you say where a write should land. Give it a write and it becomes a derived value you can bind two ways - here, Fahrenheit has no state of its own at all.

Type in either box. fahrenheit holds nothing of its own - it is a computed with a write, so the typing lands in celsius, and it reads as: mild.

apps/features.js - a writable computedview whole file
/**
 * A writable computed - a derived value you can bind two ways.
 *
 * `fahrenheit` has no state of its own. Reading it converts `celsius`;
 * writing it converts back and stores the result in `celsius`. That is what
 * lets `data-model="fahrenheit.value"` sit on an input at all: without a
 * `write`, the typing would land on a cached read and vanish.
 */
function temperature() {
    // The one piece of state, held at full precision.
    const celsius = observable(20);

    const round = (n) => Math.round(n * 10) / 10;

    // Both inputs bind to writable computeds. Each rounds only what it SHOWS
    // and writes back exactly, so a value typed into either box reads back
    // unchanged and never fights the typing.
    const celsiusShown = computed({
        read: () => (celsius.value === null ? null : round(celsius.value)),
        write: (c) => { celsius.value = c; }
    });

    const fahrenheit = computed({
        read: () => (celsius.value === null ? null : round(celsius.value * 9 / 5 + 32)),
        write: (f) => { celsius.value = f === null ? null : (f - 32) * 5 / 9; }
    });

    const feel = computed(() => {
        const c = celsius.value;
        if (c === null) return 'type a temperature';
        if (c < 5) return 'cold - coat on';
        if (c < 18) return 'mild';
        if (c < 26) return 'warm';
        return 'hot - find some shade';
    });

    return {celsius: celsiusShown, fahrenheit, feel};
}

Without the write, data-model="fahrenheit.value" would assign onto a cached read and every keystroke would vanish on the next recompute - so assigning to a read-only computed warns instead, naming it.

The rules worth knowing

.value everywhere The same spelling in JavaScript and in a template. There is no unwrapping magic - see safety.
peek() Read without registering a dependency - how an effect reads a value it must not react to.
The change gate A write always lands; notification happens only when the value actually changed (deep equality by default). Mutating an object in place and assigning it back is invisible - produce a new value.
observableArray push, splice, remove, sort and the rest notify; .length and indexOf are tracked.
subscribe Fires synchronously at the write, for code that wants a callback rather than a binding.
Disposal Every entry point returns something to tear down with - dispose(), destroy() or off().

Full detail: the reference.

Next: bindings All features