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.
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.
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 + yTo 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`);
}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}`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.
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);
}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.
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}`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}`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);
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.
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.
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.
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`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.
invalidation
promise, Dataflow automatically cleans up values that are AbortController, Symbol.dispose,
or Symbol.asyncDispose.
.abort() is calledThis 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)`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}`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:
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(", ")}]</>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:
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.
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:
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.