Reactivity Model
bQuery's reactivity is fine-grained and signal-based. There is no virtual DOM and no component-level re-rendering: when a value changes, only the effects and computeds that read that value re-run.
This page summarises the mental model. The full API and the async / HTTP / realtime sub-system live in the Reactive module guide.
The four primitives
import { signal, computed, effect, batch } from '@bquery/bquery/reactive';signal(initial)— a writable reactive cell. Read via.valueto subscribe; write via.value = ...or.update(fn)to notify.computed(() => ...)— a read-only derived value. Memoized: while nothing subscribes to it, it recomputes lazily on the next read after a dependency changes. Once it has subscribers, a dependency change re-validates it so subscribers are only notified when the value actually changed (Object.is).effect(() => ...)— a side-effect callback. Runs once eagerly, then again whenever any signal read inside it changes.batch(() => ...)— coalesces notifications: writes inside the callback trigger a single flush when the batch ends instead of notifying synchronously per write. Observers re-triggered transitively during that flush run again in a follow-up pass.
const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => {
console.log('doubled is', doubled.value);
});
batch(() => {
count.value = 1;
count.value = 2;
}); // effect logs once with doubled === 4Tracked vs untracked reads
A read is tracked when it happens inside an active reactive context (an effect, computed, watcher, or render function). Tracked reads subscribe the surrounding context to the signal.
To read without subscribing:
signal.peek()— read once, do not register a dependency.untrack(() => …)— run a block whose reads are not tracked.
effect(() => {
// Subscribed to user.value, not to debugMode.value
if (untrack(() => debugMode.value)) {
console.log(user.value);
}
});Scopes and disposal
Effects and computeds that escape their host (a component, a route, a request) leak. Use a scope to dispose them together:
import { effectScope, onScopeDispose } from '@bquery/bquery/reactive';
const scope = effectScope();
scope.run(() => {
effect(() => /* … */);
onScopeDispose(() => /* cleanup */);
});
scope.stop(); // disposes all effects/computeds registered insideComponents collect their use* composables (useSignal, useComputed, useEffect) in a component scope and dispose them on disconnect — but a bare effect() created inside a component is not auto-collected. Composables like useResource() don't create a scope either; call the dispose() they return. Use a manual effectScope() whenever you create reactivity outside such an owner.
Watch with comparison
watch() runs a callback with (newValue, oldValue) whenever a signal or computed changes. Debounced and throttled variants share the same callback shape:
import { watch, watchDebounce, watchThrottle } from '@bquery/bquery/reactive';
watch(query, (next, prev) => console.log(`query: ${prev} → ${next}`));
watchDebounce(query, fetchSuggestions, 200);
watchThrottle(scrollY, syncScroll, 16);Async data primitives
Beyond the four primitives, the reactive module exposes a signal-shaped async layer:
useAsyncData()/useFetch()/createUseFetch()— async resources as signals withdata,error,pending,refresh.usePolling(),usePaginatedFetch(),useInfiniteFetch()— long-running and paginated fetches.useWebSocket(),useWebSocketChannel(),useEventSource()— realtime transports.useResource(),useResourceList(),useSubmit()— REST-shaped resources with optimistic updates.createHttp(),http,HttpError— imperative HTTP client.createRestClient(),createRequestQueue(),deduplicateRequest()— REST helpers and concurrency utilities.
All of these expose signals, so the rest of the framework (view directives, components, devtools timeline) plugs in without ceremony.
Mental model — what to internalize
- Signals are the unit of change. Anything that should "react" must ultimately read a signal.
- Reads register, writes notify. This means
.valueand.peek()are not interchangeable. - Effects own subscriptions; scopes own effects. When something should die, dispose its scope.
- Batches collapse churn. When updating multiple signals together, wrap them in
batch(). - Async is just signals. The async layer is built on the same primitives; you can integrate any custom transport by exposing it as a signal.
Common pitfalls
- Reading
.valueinside an unrelated handler will silently subscribe the surrounding effect — use.peek()if you only want the current value. - Arrow functions for store actions can break the intended
thiscontext. Use method shorthand syntax. - Forgetting to dispose a scope outside of a component leaks effects and any DOM subscriptions they created.
See also
- Reactive module guide — full API, async layer, recipes
- Store — signal-based state containers built on these primitives
- View — declarative
bq-*directives that read signals - Devtools — inspect signals, computeds, effects, and the timeline at runtime