Reactivity

Dataflow runs like a spreadsheet: code re-runs automatically when referenced variables change. This brings:

Unlike reactive libraries that require special APIs for every reactive value, Dataflow's core reactivity is implemented at the language layer with a minimal API. It's vanilla JavaScript, but the code runs automatically. Code blocks run in topological order determined by variable references (a.k.a. dataflow), rather than in top-down document order.

Reactive Scope

All top-level variables declared in reactive scripts are shared within the same reactive scope. By default, the entire page is a single reactive scope - any top-level variable can be referenced from any other reactive script on the page.

For isolated scopes (useful with server-side rendering and HTML-over-the-wire tools like HTMX), see Reactive Islands.

Top-level Variables

A top-level variable declared in one reactive script can be referenced in another. So if you say:

const x = 1, y = 2;

Then you can reference x and y elsewhere on the page. Top-level variable declarations are effectively hoisted:

x + y

To prevent variables from being visible outside the current block, make them local with a block statement (curly braces):

{
    const z = 3;
    display(`z is ${z} but not visible outside this block`);
}

Promises

Any promise declared in a reactive script is implicitly awaited when referenced from another code block. This means you can write asynchronous code without explicit await keywords across block boundaries.

const data = fetch('https://api.github.com/repos/octocat/Hello-World')
    .then(r => r.json());
`${data.name}: ${data.description}`

Top-Level Await

You can use await directly at the top level of a reactive script without wrapping it in an async function. This makes working with async APIs more natural:

const response = await fetch('https://api.github.com/repos/octocat/Hello-World');
const repo = await response.json();
`${repo.name}: ${repo.description}`

This is equivalent to the promise-based approach in the previous section, but often reads more clearly for sequential async operations.

For Await...Of

Top-level for await...of loops are also supported for iterating over async iterables:

const slow_source = raw(async function* () {
    let count = 0;
    while (true) {
        yield count++;
        await new Promise(resolve => setTimeout(resolve, 1000));
    }
});
for await (const i of slow_source()) {
    display(i);
}

Generators

Values that change over time - such as interactive inputs, animation parameters, or streaming data - can be represented as generators. When a top-level generator is declared, code in other blocks sees the generator's latest yielded value and re-runs each time the generator yields a new value.

As with promises, implicit iteration of generators only applies across code blocks, not within a code block.

Synchronous Generators

A synchronous generator without any explicit delays will yield once every animation frame, typically 60 times per second:

const i = function* () {
    for (let i = 0; true; ++i) {
        yield i;
    }
}
`Frame count: ${i}`

Async Generators

An async generator can control its own timing. Here's one that yields once per second:

const j = async function* () {
    for (let j = 0; true; ++j) {
        yield j;
        await new Promise((resolve) => setTimeout(resolve, 1000));
    }
}
`Seconds elapsed: ${j}`

Animation Example

Generators make animation declarative - the code runs automatically whenever the frame count changes:

const canvas = display(<canvas width={width} height="32"></canvas>);
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#4269d0";
context.fillRect((i % (canvas.width + 32)) - 32, 0, 32, 32);

The now Generator

Dataflow provides a built-in now generator that yields the current timestamp (Date.now()) on every animation frame:

`The current time is ${new Date(now).toLocaleTimeString("en-GB")}.`

This is useful for animations, clocks, and any time-based reactive updates.

The width Reactive Value

Dataflow provides a built-in width reactive value that tracks the width of the code block's parent container. When the container resizes, any code referencing width automatically re-runs:

<div style="background-color: #4269d0; color: white; padding: 1rem; box-sizing: border-box; width: 100%;">
    Container width: {width}px
</div>

Try resizing your browser window to see the value update.

Note: Dataflow's width tracks the parent container's width, not the window width. This is more useful for responsive components that need to adapt to their actual available space. It replaces Observable Framework's resize() function with a simpler API - just use width as a value.

Comparison with Observable Framework

In Observable Framework, the implicit width variable tracks the window width (specifically the <main> element). Dataflow's width instead tracks each code block's parent container, which is more flexible for embedded components.

If you need window-level width (like Observable Framework), you can use event to create your own:

const windowWidth = events(window, "resize", () => window.innerWidth, window.innerWidth);
`Window width: ${windowWidth}px`

Invalidation

With reactive evaluation, code blocks can run multiple times in response to changing inputs. If a node returns a resource that needs cleanup when new inputs arrive, Dataflow handles this automatically.

Note: Unlike Observable Framework where you must manually await an invalidation promise, Dataflow automatically cleans up values that are AbortController, Symbol.dispose, or Symbol.asyncDispose.

AbortController Example

This example simulates a search API where broad queries (like "a") return more results and take longer, while specific queries (like "apple") return fewer results faster. Without cancellation, a slow broad query could return after a fast specific query and overwrite the results. Try typing quickly to see requests being aborted:

const query = view(<input type="text" placeholder="Type to search..." value="a"/>);
// Fake API
const search = async (query, signal) => {
    const length = Math.max(1, 100 - query.length * 15);
    const delay = length * 20;

    return new Promise((resolve) => {
        const id = setTimeout(() => resolve({query, length, delay}), delay);
        signal.addEventListener('abort', () => {
            clearTimeout(id);
            display(`Aborted "${query}"`);
        }, {once: true});
    })
};
// When a new query is observed a new controller will be created and the old will be aborted automatically
const controller = new AbortController();
const results = search(query, controller.signal);
`Found ${results.length} results for "${results.query}" (${results.delay}ms)`

Disposable Example

Using the standard Symbol.dispose protocol:

const resourceId = view(<input type="range" min="1" max="10" value="5"/>);
const resource = {
    resourceId,
    [Symbol.dispose]() {
        display(`Disposed resource with id ${this.resourceId}`);
    }
};
`Resource id: ${resource.resourceId}`

Observe

The observe function is a general way to create a generator that "pushes" events asynchronously. It takes an initializer function and passes it a notify callback. The optional second argument provides an initial value, and the initializer can return a disposal function for cleanup:

Note: Dataflow's observe(init, initialValue, terminate) adds two arguments beyond Observable Framework's Generators.observe(init): initialValue yields immediately without calling notify(), and terminate is a predicate to stop iteration (defaults to stopping on undefined).
const pointer = observe((notify) => {
    const handler = (event) => notify([event.clientX, event.clientY]);
    document.addEventListener("pointermove", handler);
    return () => document.removeEventListener("pointermove", handler);
}, [0, 0]);
// NB: This can be also be defined with events function as a one-liner
// const pointer = events(document, 'pointermove', ev => [ev.clientX, ev.clientY], [0, 0])
<>Pointer: [{pointer.map(Math.round).join(", ")}]</>

Mutable

Normally, only the code block that declares a variable can modify it. Use mutable to create a reactive value that can be modified from other code blocks. This is similar to signals in other frameworks or setState in React - use it when you need state changes to flow back up the dependency graph:

Note: Dataflow adds an update(fn) method for functional updates, e.g. list.update(arr => [...arr, item]).
const count = mutable(0);
const increment = () => count.value++;
const reset = () => count.value = 0;
<div>
    <button onclick={increment}>Increment</button>
    <button onclick={reset}>Reset</button>
    <span style="margin-left: 1em">Count: {count}</span>
</div>

Within the defining code block, count is a Mutable object and .value can be read and written. In other code blocks, count yields the current value (not the Mutable object). To modify a mutable from another block, expose functions like increment and reset above.

Raw

Dataflow automatically processes certain values when they flow between reactive blocks - promises are awaited, generators are iterated, and their yielded values trigger re-evaluation. Sometimes you want to pass these values through without this automatic processing. The raw function marks a value to be passed as-is:

const generatorFn = raw(function* () {
    yield 1;
    yield 2;
    yield 3;
});
// generatorFn is the function itself, not an iterated value
const values = [...generatorFn()];
`Values: ${values.join(', ')}`

This is particularly useful when you want to:

Note: Without raw, a generator assigned to a top-level variable would be automatically iterated by Dataflow, with each yielded value triggering dependent blocks to re-run. With raw, you get the generator function itself.