Skip to content

Reactive primitives

Use reactivity on its own when you need observable data without mounting any HTML. Start with state, derive a value, and react to changes.

The standalone reactivity entry works without a DOM:

import { batch, computed, createScope, effect, signal, state } from '@jnreis/briojs/reactivity';
const scope = createScope();
scope.run(() => {
const cart = state({ items: [{ price: 4, quantity: 2 }] });
const total = computed(() =>
cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
effect(() => console.log(total.value));
batch(() => {
cart.items[0].price = 5;
cart.items[0].quantity = 3;
});
});
scope.dispose(); // Stops effects and nested scopes created in the scope.
PrimitivePurpose
state(value)Observe plain objects and arrays; preserve cycles and shared references.
signal(value)A reactive .value for primitives and replaceable values.
computed(fn)Lazy, cached derived value, invalidated by the values it reads.
effect(fn)Recollect dependencies each run; return an idempotent stop function.
batch(fn)Run affected effects once after a synchronous group of writes.
flush()Run pending effects immediately, even inside a batch or event handler. Later writes still apply when the batch ends.
untrack(fn)Return fn’s result without subscribing the surrounding effect/computed to its reads.

createScope() owns nested scopes and effect cleanup. onCleanup(fn) registers cleanup in the current scope and errors if there is no active scope. A computed created outside a scope can be stopped with .dispose().

When called directly in an effect body, onCleanup(fn) is per run:

  • It runs before the effect’s next run and when the effect stops or its scope is disposed. This works in unowned effects too.
  • Cleanup inside a nested scope.run() keeps that scope’s lifetime.
  • As with scope cleanups, the returned function runs the cleanup early, at most once.
effect(() => {
if (!settings.live) return;
const unsubscribe = feed.subscribe(onMessage);
onCleanup(unsubscribe); // Unsubscribes when live turns off, before re-subscribing, and on stop.
});
effect(() => {
const days = filters.days; // Tracked: reloads when the range changes.
untrack(() => load(days, filters)); // Reads inside load() do not become dependencies.
});

Effects run synchronously by default. Explicit batches are synchronous too; there is no hidden next-tick scheduler.

Event handlers are already batched. Wrap several writes made elsewhere (timers, fetch callbacks, scripts) in batch() so lists and bindings update once instead of after every write.

Mutating array methods (push, splice, sort, …) run on the underlying array, then notify only changed indexes and length when it changes. Returned values (pop(), shift(), splice() results) and sort comparator arguments are the same reactive values that reads return.

BindingWhen Brio writes to the DOM
b-text, ${…}, primitive b-bind-*Only when the value differs from the last written value.
b-bind-classOnly changed class names are added/removed; classes added by other code stay.
Object values, such as a style objectAlways reapplied, since they may have changed in place.
  • Owners update before their children. Pending effects run in creation order. An owner (outlet, branch, component, or effect mounting child scopes) is created before its child effects, so it disposes stale children before they can run against new values.
  • An effect does not trigger itself by writing a value it read in the same run.
  • Writes from other code are not lost. If a nested effect or untracked code writes an already-read value while an effect is still running, the effect runs again immediately after its current run. One example is an auth guard calling router.navigate() during route setup.
  • Repeated reruns are bounded. After 100 consecutive such runs, Brio throws A Brio effect keeps re-triggering itself.
  • Derived values stay coherent. A write invalidates every dependent computed before any effect reruns. A computed read later in the same batch or handler already reflects the write; effects never see half-updated derived state.
  • Computed cycles surface explicitly. A computed reading itself directly or through other computeds throws Circular Brio computed dependency. It recovers once the cycle is broken.

Use a resource for data that follows reactive inputs, such as search results. It keeps loading, data, and error state together and prevents stale requests from overwriting newer results.

resource(fetcher) is exported from @jnreis/briojs and @jnreis/briojs/reactivity, and is available as brio.resource in the browser bundles. It starts immediately and tracks reactive dependencies read synchronously by the fetcher (before its first await). It does not block mounting or provide server rendering.

import { createScope, resource, signal } from '@jnreis/briojs/reactivity';
const scope = createScope();
const query = signal('Brio');
const results = scope.run(() => resource(async signal => {
const url = '/api/search?q=' + encodeURIComponent(query.value);
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
return response.json();
}));
// Bind results.data.value, results.loading.value, and results.error.value.
query.value = 'HTML'; // Aborts the previous execution and starts a new one.
await results.refetch(); // Explicit refresh using the current query.
scope.dispose(); // Stops dependency tracking and aborts pending work.
  • The fetcher receives a fresh AbortSignal on every execution. Existing zero-argument fetchers still work.
  • Starting a new execution aborts the previous one. Only the latest execution may publish data, error, or completion, even if a fetcher ignores cancellation. An obsolete refetch() promise still waits for its own fetcher to settle; aborting does not forcibly terminate arbitrary asynchronous work.
  • Refreshing retains existing data, clears the previous error, and sets loading true. A latest successful execution replaces data (including with undefined). A latest failure retains data, records the original error, and clears loading. Related status changes are batched for coherent reactive notifications.
  • Fetcher failures, including synchronous throws, are recorded in error.value; refetch() resolves after recording them, rather than rethrowing them. Render errors visibly. Aborted superseded/disposed executions do not publish errors; other fetcher errors are not silently discarded.
  • A resource belongs to the scope active at construction. Component setup, scoped plugins, or an explicit scope.run() can own it; the app’s state factory alone does not provide an owner scope.
  • Standalone resources work without an active scope, but must be explicitly disposed with results.dispose(). Disposal is idempotent, stops dependency tracking, aborts pending work, sets loading false, and preserves settled data/error. No late completion publishes after disposal. Subsequent refetch() calls reject with a disposed-resource error.

The utility does not cache, deduplicate, retry, or impose timeouts. Actual cancellation requires the fetcher to pass its signal to a cancellable operation. Signals expose mutable values; application code should treat resource outputs as read-only and perform refreshes through refetch().