Advanced Reactivity
A tour of the engine. What actually happens between this.foo = 1 and your DOM updating, and the runtime decisions the framework makes along the way.
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, and pools all share the same reactive engine. There is one dependency-graph core, and each component, store, plugin, or pool gets one handle onto it. The engine's behavior is identical across the four kinds.
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.
This architectural decision is one of the framework's structural strengths. 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 four.
Practical consequence: 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.
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.
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, no compiler, and no manual useState-style declaration of what depends on what. The graph is implicit in the reads and writes your code performs.
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.
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 it matches what users intuitively expect (set three things, render once) 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 reach for it only at the interop boundary.
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. Two properties keep computeds cheap, and they apply to every computed equally. There is no tiering, no promotion, and nothing to opt into.
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 actually depends on actually moves.
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 lands on the same value stays contained and does not cascade into the effects and computeds that read it.
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's working assumption is to do 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.
Keeping the render pass on the microtask rather than requestAnimationFrame is deliberate: 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 genuinely needs to align with paint or animation timing: the initial-mount bootstrap, transitions, and conditional reveals. Pools are the one part of the system that run on their own animation-frame loop, by design, because they exist for per-frame rendering.
data-render reveal, which align to the animation frame on purpose. 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, and pools 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. Reaching across entities, a component computed that reads a store's value, goes through a dedicated tracking surface so that the dependency is still recorded.
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
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. Two windows are worth knowing.
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.
One related constraint: 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; ownership is what makes teardown deterministic.
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.
The constraint: 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:
- Click row A.
openFieldchanges fromnullto'status',openIdchanges fromnullto'a'. Every binding that trackedopenFieldwakes and re-evaluates. This time the&&doesn't short-circuit (left side is truthy), soopenIdgets read and tracked. Every binding now has both fields as dependencies. UI updates correctly. - Click row B.
openFieldstays'status'. OnlyopenIdchanges (from'a'to'b'). Bindings that tracked both fields wake. But bindings whose initial evaluation had short-circuited atopenFieldmay 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). It is not a WildflowerJS bug; it is the price of "no compiler." 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 intentionally 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-nameaccessor 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 howbatch()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)orstartBatch()/applyBatch()),flushSync(), andwildflower.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, the engine fades into the background. That is the design goal.
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.