Advanced Reactivity

What happens between this.foo = 1 and your DOM updating, and the runtime decisions the framework makes along the way.

Audience: framework users who want to understand why their code behaves the way it does. Most code never needs this level of detail. Nothing here changes how you write components.

The basic Reactivity page covers what you need to write code that works. This page covers what the engine does underneath: the dependency graph it builds as your code runs, how a single mutation reaches the DOM, and the failure modes the design is built to prevent.

1. One engine, four entity types

Components, stores, plugins, pools, and queries all share the same reactive engine. There is one dependency-graph core, and each declaration gets one handle onto it; declaring a query creates one too, its backing store's. The engine's behavior is identical across all of them, because the graph never distinguished between kinds. A component is a handle plus DOM ownership; a query is a handle plus a source controller owning freshness, race discipline, and lifecycle. What differs is what surrounds the handle, never how the handle reacts.

The four entity types (component, store, plugin, pool) all share the same reactive core

One terminology note about pools, because the framework overloads the word "entity": a pool is one of the four entity-types and gets one handle on the engine. The items inside a pool are also called entities (per the per-entity declaration shape: entity: { state, computed, methods }) but those items share the pool's single handle rather than each having their own. A pool with 2600 boids is one handle, not 2600. That is the whole point of pools: high-frequency rendering at scale, without paying for thousands of independent reactive handles.

Each kind wraps the same engine in slightly different lifecycle hooks: a component has DOM, a store does not, a pool has many items sharing one renderer and one handle. But the proxy traps, the effect scheduler, the computed evaluator, and the cross-entity bridge are the same code for all of them.

Anything you learn about how a component reacts applies unchanged to a store, a plugin, or a pool. Computed properties validate the same way. Effects schedule the same way. Cross-entity reads work the same way.

Why it matters: the reactive graph core plus the per-entity handle is the entire reactive system. There is no separate code path for stores and no different proxy for plugins. Because there is only one implementation, you can learn the framework end to end.

2. The proxy in the middle

Your state object is a JavaScript Proxy. Every read passes through the get trap; every write passes through the set trap. Those two traps are where reactivity is implemented.

Get trap records a dependency edge; set trap wakes the observers of the value

The get trap does dependency tracking. When code inside an effect or a computed property reads this.user.name, the get trap records an edge: the currently-running effect depends on that value. The edge is what lets the framework re-run the effect later if the value changes.

The set trap does notification. When code writes this.user.name = "Bob", the set trap updates the value and wakes the observers of that value: the effects and computed properties that read it. Nothing else is touched. There is no "which properties changed" scan, because the property that changed is the one being written.

The two traps together form a dependency graph that is built dynamically by code execution. There is no static analysis step, compiler, or manual useState-style declaration of what depends on what. The graph is implicit in the reads and writes your code performs.

Reads outside a reactive context: the get trap only records an edge when an effect or computed is currently executing. Reads outside any reactive context (for example, in a console log or a regular method called manually) pass through cleanly without recording anything. This is what makes the framework work without explicit subscribe/unsubscribe ceremony.

3. How a write reaches the DOM

A write to this.X does two things synchronously inside the set trap: it updates the value, and it marks the observers of that value as needing to run. The observers do not run immediately. They are queued and flushed on a microtask, so several writes in the same synchronous block coalesce into a single update pass.

A write marks observers and schedules a microtask flush; multiple writes coalesce into one pass

The default: microtask-coalesced

Writes happen synchronously into the underlying state object, but the re-runs (effect re-evaluation, DOM updates, computed re-evaluation) are deferred to a microtask. Multiple writes in the same synchronous block coalesce: by the time the microtask drains, only the final value of each path is visible, and each affected effect runs once.

// Three writes, one update pass.
this.count = 1
this.name = "x"
this.items.push(newItem)
// effects run when the microtask drains, after this block

This is the right default because setting three things and rendering once is what the code implies, and it happens before the browser paints, so the user never sees an intermediate frame.

Batch: defer the flush across a block you control

The microtask already coalesces writes within one synchronous block. wildflower.batch(fn) (and the startBatch() / applyBatch() pair) extends that to a block you define explicitly, including across awaits, and is the mechanism the framework itself uses around compound operations that should be atomic from the rendering system's perspective, such as form submission and props propagation.

wildflower.batch(() => {
    this.count = 1
    this.name = "x"
    this.items.push(newItem)
})
// effects run here, once, after the batch closes

Synchronous flush: for interop

When non-reactive code needs to observe a state change immediately (test harnesses, certain animation libraries, headless rendering pipelines), flushSync() drains the pending effects right now instead of waiting for the microtask. It costs more because it cannot coalesce with later writes in the same block, so use it only at the interop boundary.

Internal fast paths: for the most common case, a single text binding whose value nothing else reads, the engine can write the new text straight to the DOM node and skip waking an effect at all. This is invisible to your code and never changes observable behavior; it is why a tight loop of single-field updates stays cheap.

4. How computed properties stay cheap

A computed property is a node in the same dependency graph. It tracks what it reads, exactly like an effect. Both properties below apply to every computed equally. There is no tiering or promotion, and nothing to opt into.

A computed recomputes only when a source actually changed, and propagates only when its own result changed

It recomputes only when a source actually changed

Computed properties are lazy and validated on read. When something a computed read might have changed, the computed is flagged to check. On the next read, it confirms whether any of its sources truly changed value. If none did, it returns its cached result with no work. A computed downstream of a busy part of the graph is only re-evaluated when a value it depends on actually changes.

It propagates only when its own result changed

When a computed does re-evaluate, it compares the new result to the previous one. If they are equal, nothing downstream is woken. A recompute that produces the same value does not cascade into the effects and computeds that read it.

In practice: deriving state with computed properties is close to free when inputs are stable, and chains of computeds do not amplify a change that does not actually alter a value. A computed like fullName() { return this.firstName + ' ' + this.lastName } re-runs only when one of those two fields changes, and only wakes the bindings reading fullName if the concatenated result is different.

5. Timing: microtask first, paint after

The framework does as much as possible on the microtask, because microtasks run before the browser paints and have no animation-frame jitter. After a write, the effect flush, the binding and list updates, the computed re-evaluations, and the component render pass all run on the microtask, before the next paint.

Timeline: synchronous code, microtask drain (effects, render), then browser paint

The render pass stays on the microtask rather than requestAnimationFrame so an interactive change commits in the same frame as the interaction, instead of waiting a frame for an animation callback. requestAnimationFrame is reserved for work that must align with paint or animation timing: the initial-mount bootstrap, transitions, and conditional reveals. Pools are the one part of the system that runs on its own animation-frame loop, because they exist for per-frame rendering.

If you see something update "a frame late": that is almost always a transition or a data-render reveal, which align to the animation frame. Ordinary data-bind, data-list, and computed updates land on the same frame as the interaction that triggered them.

6. Cross-entity reactivity

Components, stores, plugins, pools, and queries all share the one dependency graph (section 1). Within an entity, dependency tracking is direct: reading a value inside an effect records a graph edge, and writing it wakes the observers. A read that reaches across entities, such as a component computed reading a store's value or a query's rows, goes through a dedicated tracking surface so the dependency is still recorded.

Cross-entity reads go through a tracking proxy that records the dependency; when the source entity changes, the framework re-evaluates the dependents that read it

The tracking surface is the proxy returned by wildflower.getStore(), wildflower.getComponent(), and the $entity-name accessor. When you read wildflower.getStore('cart').total from inside component A's computed, you are not reading the cart's raw state; you are reading through a tracking proxy that records on A's side that "this computed depends on the cart's total."

That recorded dependency is what drives the update. When the cart's state changes, the framework re-evaluates the dependents that read it, so A's computed recomputes and any bindings reading it update. The dependent does not poll, and it does not wire up its own callback by hand: the cross-entity dependency is registered once, when the read happens, and is torn down with the dependent when it is destroyed, so there is no manual subscribe or unsubscribe to manage.

// In component A
computed: {
    cartTotal() {
        // returns the cart's total, reactively
        return wildflower.getStore('cart').total
    }
}

// In the cart store, somewhere
this.items.push(item)  // changes the cart
// the framework re-evaluates A's cartTotal, which read the cart through getStore
The supported pattern: always reach across entities via getStore(), getComponent(), or $entity-name. If you grab a raw reference to another entity's state object directly (for example by capturing it in a closure), the tracking proxy is bypassed and the dependency is silently lost. The reactive update will not fire. The framework has no way to detect this; it relies on the convention that cross-entity reads always go through the tracking surface.

7. Lifecycle windows that change the rules

The reactive engine behaves slightly differently depending on where in a component's life the operation happens.

Pre-init action queueing

If a DOM event (a click, an input event, a keydown) fires after a component's element exists but before its init() hook has finished, the action handler does not run immediately. It is queued. When init() returns, queued handlers replay in their original order. This matters most for components that subscribe to slow-loading stores: init may await a Promise.all of subscriptions for several macrotasks, and any user interaction during that window would otherwise hit a partially-initialized component.

Replayed handlers see the original DOM event, but with one limitation: event.preventDefault() is a no-op by replay time because the browser has already processed the default action. For forms that need to reliably block submission across the replay boundary, use data-event-prevent on the form element. The framework intercepts the event before user code runs.

A method named exactly init, beforeInit, destroy, beforeDestroy, onUpdate, beforeUpdate, onError, or tick is treated as a framework-driven lifecycle hook and is not queueable. Don't reuse those names for action handlers. The most common trap is tick: it gets called every animation frame for components in the pool loop, not on click.

Destroy-time cleanup

Effects are owned by a disposal scope that mirrors the component (and, for lists, each row). When a component is destroyed, the framework disposes its scope, and every effect and dependency edge created under it goes with it, in one pass. There is no registry to scan and no periodic garbage collection looking for orphaned effects. Teardown is deterministic because the scope owns the effects.

The user's destroy() hook fires before the scope is torn down, and it can safely mutate state: any effects those mutations would have woken are about to be disposed along with the scope, so nothing leaks past teardown.

8. Conditional reads and dependency tracking

The framework tracks dependencies by intercepting reads through the state proxy. When you read this.foo inside a computed or effect, the get trap records "this binding depends on foo." When that field later changes, every binding that read it is queued to re-run.

Only reads that actually execute get tracked. JavaScript's short-circuit semantics for &&, ||, and ternary ?: mean that some reads in the source code don't always happen at runtime. Consider:

computed: {
    isOpen(item) {
        return this.openField === 'status' && this.openId === item.id;
    }
}

When isOpen first evaluates with openField equal to null, the && short-circuits and this.openId is never read. The binding's tracked dependencies are { openField } only. Now imagine the user flow that opens a popover and then switches to a different row:

  1. Click row A. openField changes from null to 'status', openId changes from null to 'a'. Every binding that tracked openField wakes and re-evaluates. This time the && doesn't short-circuit (left side is truthy), so openId gets read and tracked. Every binding now has both fields as dependencies. UI updates correctly.
  2. Click row B. openField stays 'status'. Only openId changes (from 'a' to 'b'). Bindings that tracked both fields wake. But bindings whose initial evaluation had short-circuited at openField may have tracked only that one field, depending on render order. Those bindings don't wake. Their rows' DOM never updates. UI is wrong.

The symptom is non-deterministic across reloads: sometimes the framework happens to evaluate every row's binding under a state shape that reads both fields, sometimes it doesn't. Initial render order, click order, and which row was first to evaluate truthy all influence which bindings have complete dependency sets.

This is a property of all runtime-proxy reactive systems (Vue, Solid, MobX, Preact Signals). The compiler-based alternative (Svelte, Vue's <script setup> with reactive transforms) extracts dependencies via AST analysis at build time and records them regardless of control flow. WildflowerJS trades compile-time analysis for the no-build-step authoring story, so this characteristic comes with the runtime-proxy family.

The fix is to read all potentially-relevant fields eagerly at the top of the computed, before any branching:

computed: {
    isOpen(item) {
        const f = this.openField;   // always read; always tracked
        const id = this.openId;     // always read; always tracked
        return f === 'status' && id === item.id;
    }
}

The eager reads force both proxy reads on every invocation, so both fields end up in the binding's dependency set from the first evaluation onward. Subsequent state changes to either field correctly wake the binding.

This pattern applies anywhere a computed or effect conditionally reads state: &&, ||, ternary, if/else, early return. The rule is mechanical: every field the computed could read on any branch should be read once before the branching begins.

9. When to think about any of this

The defaults (microtask coalescing, lazy computed validation, post-init action dispatch) are correct on their own. You don't need to know any of this to write code that works. The page exists for the cases where you want to step outside the defaults, and you need to understand the machinery in order to do that confidently.

Those cases are:

  • You are debugging a "why didn't this update?" symptom. The most common cause is a closure-captured reference to another entity's state, bypassing the tracking proxy that getStore(), getComponent(), and the $entity-name accessor would have provided. The second most common is a conditional read that never tracked a field (section 8 above). See Communication for the supported cross-entity patterns and Common Mistakes for the specific anti-patterns.
  • You are debugging a "why did this fire twice?" symptom. Look at whether a component is being re-initialized, or whether two writes you expected to coalesce actually happened in separate microtask turns (for example, separated by an await). Section 3 above describes how coalescing works and how batch() extends it across awaits.
  • You are writing a plugin or a custom directive. Plugins use the same engine as components, but your plugin's effects need to register under the right disposal scope or they will not be cleaned up at destroy. See Basic Plugins for the registration shape and Advanced Plugins for the lifecycle and effect-cleanup details.
  • You are doing animation-heavy or high-frequency work. Pools exist precisely because the per-component overhead would be prohibitive at hundreds or thousands of items updating per frame. A pool sets up one handle and one renderer regardless of how many items it holds, and runs on its own animation-frame loop. See Why Pools? for the motivating use cases, Pools for the API, and Entity Model for the per-entity declaration shape.
  • You are interoperating with non-reactive code. The batch API (wildflower.batch(fn) or startBatch()/applyBatch()), flushSync(), and wildflower.whenSettled() are the bridges into systems that cannot be retrofit to the microtask drain. The batch path is described in section 3 above; the timing model in section 5 above.

Outside those cases, you should not need to think about the engine at all.

What participates in reactivity: reads through the framework's tracking surfaces (your own this.foo properties, getStore(), getComponent(), $entity-name) participate in reactivity. Reads through anything else (closures over external references, manually captured objects) do not. When in doubt, route through the tracking surface.