Advanced Reactivity
A tour of the engine. What actually happens between this.state.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 runtime decisions that route a single mutation down different code paths, and the failure modes those decisions are designed to prevent.
1. One engine, four entity types
Components, stores, plugins, and pools all share the same reactive engine. There is one ReactiveStateManager implementation, and each component, store, plugin, or pool gets one instance of 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 RSM. 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 RSM rather than each having their own. A pool with 2600 boids is one RSM, not 2600. That is the whole point of pools: high-frequency rendering at scale, without paying for thousands of independent reactive contexts.
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 RSM. But the proxy traps, the effect scheduler, the computed evaluator, and the cross-RSM 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 promote the same way. Effects schedule the same way. Cross-entity reads work the same way.
ReactiveStateManager.js or ProxyHandlers.js, you are reading the entire reactive system. There is no separate code path for stores, no different proxy for plugins. The single-implementation property is what makes the framework possible to learn 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.
The get trap does dependency tracking. When code inside an effect or a computed property reads this.state.user.name, the get trap notes that the currently-executing reactive function depends on the path user.name. That dependency is recorded so the function can be re-run later if the path changes.
The set trap does notification. When code writes this.state.user.name = "Bob", the set trap walks the list of dependents registered for user.name (and the parent paths) and queues each for re-evaluation. It also marks any computed property that depends on user.name as dirty, so the next read of that computed will re-evaluate.
The two traps together form a complete 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. Three update paths
A single line of user code can take one of four paths through the set trap. The framework picks at runtime, by checking three conditions in order.
How the path is chosen
Every write to this.state.X enters the proxy set trap. The trap walks three checks in order, and the first one that resolves picks the path:
- Is the entity currently in batch mode? Concretely, is
wildflower._batchModeset totrue? Batch mode can be entered two ways. The user can enter it explicitly viawildflower.batch(fn)orstartBatch(). The framework also enters it automatically around certain compound operations where atomicity matters: form submission, props propagation through a component hierarchy, and batched list updates. In both cases the flag is cleared by the matchingapplyBatch()orcancelBatch(). While true, every write is recorded into the entity's_batchChangesMap and deferred. No notifications fire until the batch closes.
This path can be opted into per-call, and is also entered automatically by the framework when several internal operations need atomicity. - Is microtask batching eligible for this entity? Each
ReactiveStateManagercaches an_microtaskBatchingEligibleflag at construction time, computed once from three sources: the framework-level{ syncMode: true }option passed tonew WildflowerJS(), a per-componentdisableMicrotaskBatchingopt-out, and a global override. None of these change at runtime. If eligible (the default for almost every component), notifications go on the microtask queue and coalesce naturally with other writes in the same synchronous block.
This is a framework-level configuration choice, baked in at instantiation. You don't control it per-write. - Is this an inline-fast-path-eligible write? Only checked when batch mode is off and microtask batching is also off. The write must be a primitive (number, string, boolean, or null) assigned to a top-level property of state (not nested), with the previous value also a primitive. If all three are true, the framework runs
_inlinePrimitiveSet, which fuses several steps into one tight function call. Otherwise the write goes through the synchronous general path.
You don't control this at all. It's an internal optimization that fires when the write happens to be simple enough.
From the user's side, the path you choose explicitly is batch mode, once per batch() call. The framework can also enter batch mode on its own around compound operations like form submission and props propagation; you do not control those entries, but the underlying mechanism is the same. Microtask vs sync is decided once when you instantiate the framework. The inline fast path is invisible to user code; it is a sync-mode optimization the framework picks up on its own.
Batch mode
If the entity is currently inside wildflower.batch(fn) (or between startBatch() and applyBatch()), every write is recorded into a per-entity _batchChanges Map and deferred. No effects fire, no DOM updates, no computeds re-evaluate. When the batch applies, all recorded changes are flushed at once into a single render.
// Three writes, one render.
wildflower.batch(() => {
this.state.count = 1
this.state.name = "x"
this.state.items.push(newItem)
})
// effects fire here, after the batch closes
Microtask path (the default)
Outside batch mode, the default is microtask-batched. Writes happen synchronously into the underlying state object, but the notifications (effect re-runs, DOM updates, computed re-evaluations) are deferred to a microtask. Multiple writes in the same synchronous block coalesce naturally: by the time the microtask drains, only the final value of each path is visible.
// Same effect: three writes coalesce into one DOM update.
this.state.count = 1
this.state.name = "x"
this.state.items.push(newItem)
// no batch() call needed; the microtask handles it
Synchronous path
If the framework was constructed with { syncMode: true }, or the component opts out of microtask batching, writes notify dependents immediately and synchronously. Effects fire before the next line of user code runs.
Sync mode is for interop with non-reactive code that needs to observe state changes during the same call (testing harnesses, certain animation libraries, headless rendering pipelines). It costs more per write because it can't coalesce, and it changes the timing assumptions effects can make about each other.
Inline fast path (an internal optimization)
When all of these are true at once: not in batch mode, microtask batching is off, the property is a primitive (number, string, boolean), and the write is at the root of the state object (not nested), the framework takes a tighter inline path that fuses the set trap, version bump, dependent notification, and onStateChange dispatch into a single function (_inlinePrimitiveSet). This path is performance-only; the observable behavior is identical to the synchronous path. Most users never trigger it because microtask batching is the default.
4. The computed promotion ladder
Computed properties have three internal evaluation tiers. The framework decides which tier a computed belongs to by observing how it actually behaves; the user does not opt in.
DYNAMIC: every evaluation does full work
A new computed starts as DYNAMIC. Every read goes through the proxy, every dependency is re-tracked from scratch, and the computed is fully re-evaluated whenever any current dependency changes. This is the safe baseline; it always produces correct values, just at the highest per-read cost.
STABLE: lightweight tracking, identity verified
After two consecutive evaluations produce the same set of dependencies (same number of distinct state paths read), the framework promotes to STABLE. STABLE uses the node fast path: a cheaper code path that still tracks dependencies but skips most of the proxy machinery. On every re-evaluation, STABLE verifies that the current dependency set matches the one captured at promotion. If identity drifts (the computed reads a different set of paths than before), STABLE demotes one-way back to DYNAMIC and never re-promotes.
STATIC: proxy bypassed entirely
If a STABLE computed has no conditional syntax in its body (no if, else, ?, &&, ||, ??, no function calls), and produces consistent dependency identity for two more evaluations, it promotes again to STATIC. STATIC bypasses the proxy on reads entirely. Dependencies are baked in at promotion time and never re-tracked. This is the fastest tier.
STATIC has no demotion path. The framework only promotes a computed to STATIC if it can prove (by static inspection of the function body) that the dependency set cannot vary across evaluations. If your computed body has any conditional construct, STATIC is unreachable for it; it stays on STABLE forever.
fullName() { return this.state.firstName + ' ' + this.state.lastName } reaches STATIC. A computed like display() { return this.showFull ? this.fullName : this.firstName } stays on STABLE because of the ternary.
\w() as a conditional, blocking STATIC promotion. The result is that pure helpers (Math.max, formatDate) stay on STABLE rather than promoting to STATIC. STABLE is still fast; STATIC is just faster.
5. Effects vs render: the timer story
Two timing primitives are in play: the microtask queue and requestAnimationFrame. Different update categories run on different timers.
The framework's working assumption is: do as much work as possible in the microtask drain, because microtasks run before the browser paints and have no animation-frame jitter. Use the rAF only for work that genuinely needs to align with paint timing or that has bootstrap dependencies that the microtask cannot satisfy.
After the v1.1 unification, almost every kind of update flows through effects on the microtask: data-bind, data-bind-class, data-bind-style, data-bind-attr, data-bind-html, data-model, and the entire data-list pipeline (lists are now built on mapArray, which is effect-driven). The rAF render sweep handles only two things: data-render conditional reveals and the initial-render bootstrap when a component first mounts.
Why most things are effects now, not render-driven
Earlier versions of the framework used the rAF render sweep for nearly everything. The unification to effect-driven updates was driven by two observations: effects are inherently fine-grained (one effect per binding context, so an update cost only what needed updating), and microtask timing avoids the 16ms quantization of rAF.
The rAF render still exists because some operations are most naturally expressed as a sweep over the DOM tree (revealing conditionals, reconciling structural changes during initial mount). For those, effects would fragment the work into too many independent runs.
6. Cross-store reactivity
Every component, store, plugin, and pool has its own RSM. (As noted in section 1, items inside a pool share the pool's RSM rather than each getting their own; a pool with 2600 items is still one RSM.) Within an RSM, dependency tracking is direct: the proxy get trap registers the active effect into a per-path Map, and the set trap walks that Map. Across RSMs, dependency tracking has to bridge two separate dependency graphs.
The bridge is a tracking proxy returned by wildflower.getStore(), wildflower.getComponent(), and the $entity-name accessor. When you read wildflower.getStore('cart').total from inside a component A's computed, you are not reading the cart's raw state; you are reading through a tracking proxy whose get trap records on A's RSM that "this computed depends on the cart's total path."
The recorded dependency is stored in A's externalSources map, keyed by the source RSM's identity, with the source RSM's _globalEpoch stamped as lastEpoch. Whenever the cart's RSM mutates state, its _globalEpoch bumps. The next time component A re-evaluates the computed, it compares its cached lastEpoch to the cart's current epoch; if they differ, the computed re-evaluates fully.
Why epoch tracking, not direct subscription
Direct subscription (every cross-store dependency wires up a callback on the source) would require explicit unsubscribe on teardown, would scale poorly with many small dependents, and would create cycles when stores depend on each other. Epoch tracking is pull-based: the dependent checks on its own re-evaluation rather than being pushed into. This is cheap (one integer compare per cached source), composable (epochs propagate transitively through entity tracking proxies), and self-cleaning (when the dependent component is destroyed, its externalSources goes with it).
// In component A
computed: {
cartTotal() {
// returns the cart's total, reactively
return wildflower.getStore('cart').total
}
}
// In the cart store, somewhere
this.state.items.push(item) // bumps cart's _globalEpoch
// next time A reads cartTotal, the epoch mismatch triggers re-eval
getStore(), getComponent(), or $entity-name. If you grab a raw reference to another store's state object directly (for example by capturing it in a closure), the tracking proxy is bypassed and dependency tracking 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 phases that change the rules
The reactive engine behaves slightly differently depending on where in a component's life the operation happens. Three 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.
HTML flash queue
data-bind-html writes that happen during component initial setup are queued into a per-RSM _htmlInitialQueue rather than applied immediately. The framework drains the queue when the binding context for the affected element registers, which typically happens later in the same initialization tick. The reason is to prevent a brief flash of unsanitized or incomplete HTML between the element being parsed and the binding context being ready to render it correctly.
The drain runs lazily and is bounded to the component's own RSM, so even pathological initialization paths cannot leak the queue into a different component's lifecycle.
Destroy-time effect sweep
When a component is destroyed, the framework walks both the component instance's _effects set and the context's _effects set, disposing each. This handles two scope cases: framework-internal code creates effects with scope: instance, while user code inside init() creates effects with scope: this (where this is the context proxy). Both Sets need a sweep on teardown, with snapshots so effects that self-remove during disposal don't break iteration.
The user's destroy() hook fires before the sweep, but binding effects scheduled by state mutations inside destroy() are protected: the component's context is removed from the registry before the destroy hook runs, so binding effects that try to look up their target context find nothing and silently no-op. The combined effect is that destroy() can safely mutate state without leaking effects past teardown.
8. Conditional reads and dep tracking
The framework tracks dependencies by intercepting reads through the state proxy. When you write this.state.foo inside a computed or effect, the proxy's GET trap records "this binding depends on state.foo." When that field later changes, every binding that read it gets queued for re-evaluation.
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) {
const s = this.state;
return s.openField === 'status' && s.openId === item.id;
}
}
When isOpen first evaluates with openField equal to null, the && short-circuits and s.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 up. They all re-evaluate. 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's positioning explicitly trades compile-time analysis for the no-build-step authoring story, so this characteristic is inherited from 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 s = this.state;
const f = s.openField; // always read; always tracked
const id = s.openId; // always read; always tracked
return f === 'status' && id === item.id;
}
}
The eager destructuring forces 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 batching, automatic computed promotion, no batch mode, 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. See Communication for the supported cross-entity patterns and Common Mistakes for the specific anti-patterns to recognize. - You are debugging a "why did this fire twice?" symptom. Look at whether the same logical operation is being seen by both an effect and the rAF render sweep, or whether a component is being re-initialized. Section 5 above describes which categories of update flow through which timer.
- You are writing a plugin or a custom directive. Plugins use the same RSM as components, but your plugin's effects need to register with the right 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 RSM overhead would be prohibitive at hundreds or thousands of items updating per frame. A pool sets up one RSM and one renderer regardless of how many items it holds, and bypasses several layers of the standard reactive pipeline. 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. Sync mode (
{ syncMode: true }at framework instantiation), the batch API (wildflower.batch(fn)orstartBatch()/applyBatch()), 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.state, 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.