How Binding Works
How a data-* attribute connects a DOM element to your state, stays in sync, and cleans up after itself, all without a virtual DOM.
data-bind="username" to an element, WildflowerJS creates a small reactive effect for it: a function that reads state.username and writes the value to the element. Reading the state records a dependency edge, so when state.username changes, the framework wakes exactly that effect and updates exactly that element. Nothing else is scanned, diffed, or re-rendered.
Every reactive attribute works the same way. The attribute names what to read and how to apply it; the effect does the reading and writing; the dependency graph connects the two. The sections below cover the kinds of bindings, how one is born and cleaned up, and how to bind across entities.
Binding Types
Each reactive attribute creates an effect tuned to a particular job:
| Attribute | Purpose | What the effect does |
|---|---|---|
data-bind |
Display reactive data | Reads a value and writes it to the element's text or value |
data-action |
Handle events | Invokes a method when the bound DOM event fires |
data-show, data-render |
Conditional visibility (data-show) or full mount/unmount (data-render) |
Reads a condition and toggles visibility, or mounts/unmounts the element |
data-list |
Array rendering | Reconciles the array by identity and registers each row's bound fields on a per-list update dispatcher |
data-model |
Two-way binding | Writes state to the input and writes input changes back to state |
data-* attributes also support a data-wf-* prefix (e.g., data-wf-bind, data-wf-action). Use the wf prefix when integrating with third-party libraries that may conflict with standard data-* attributes. Both prefixes are functionally identical.
The Life of a Binding
A binding goes through three phases, and none of them involve a central registry or a cleanup timer.
1. Creation
When the framework scans the DOM (during component initialization, or after dynamic content is added), it finds elements with reactive attributes and creates the matching effect for each:
data-bind="username"→ an effect that readsstate.usernameand writes it to the elementdata-action="save"→ a handler that callssave()when the element is clickeddata-list="items"→ a list reconciler that renders one row per item and registers each row's bound fields for targeted updatesdata-show="isVisible"→ an effect that readsisVisibleand toggles the element's visibilitydata-model="email"→ a two-way binding between the input andstate.email
On its first run, each effect reads the state it needs. Those reads record dependency edges in the graph, so the effect is now linked to exactly the values it used.
2. Active
Once created, effects respond to change through their edges:
- Display bindings re-run when a value they read changes, and write the new value to their element
- Actions wait for their DOM event and invoke the bound method
- Lists reconcile by identity on array change, touching the DOM only for items that were added, removed, or moved. An in-place property change (
items[3].qty = 5) routes through the list's dispatcher to exactly the bindings that read that field: for a field with a single plain binding, that is one direct write to one element, with no re-render of the row and no scan of its siblings - Conditionals re-evaluate when their condition's inputs change and show, hide, mount, or unmount accordingly
- Models sync both ways: input changes write state, state changes update the input
Because each effect only depends on the values it actually read, a change to state.username wakes the username binding and nothing else.
3. Cleanup
Bindings are owned by the component or list that created them. When that owner goes away, its registrations and their edges go with it, in a single pass:
- Component destruction: destroying a component disposes every effect it owns
- List item removal: removing an item clears that row's registrations from the list's dispatcher; the list itself owns one dispatcher, not one effect per row
- Dynamic content replacement: replacing an element's content disposes the bindings attached to the old content
There is no registry to scan and no periodic garbage collection. Ownership is what makes cleanup deterministic: a binding cannot outlive the element it was created for.
await, timeout, or next event handler runs, the DOM reflects your state, and within any frame the user sees a consistent picture. Some updates apply immediately at the moment of the write and others coalesce on the microtask; which is which is an internal optimization detail that may change between releases, so don't build on intra-tick DOM timing.data-show keeps an element's effect alive when hiding; it only toggles CSS visibility, which is efficient for frequently toggled content. For content that should fully unmount and dispose its effects, use data-render instead.
Binding Across Entities
To read another entity's state from a template, use the $ universal accessor: data-bind="$entity.path". For JavaScript-level access, use subscribe with this.stores for stores, or wildflower.getComponent() for components. All three record a cross-entity dependency, so the binding updates when the source changes.
<!-- HTML: bind directly to another entity's state -->
<span data-bind="$user-session.user.name"></span>
<div data-show="$auth.isLoggedIn">Welcome!</div>
<span data-bind="$cart.itemCount"></span>
// JavaScript: use subscribe + this.stores for store access
wildflower.component('cart-badge', {
subscribe: {
cart: ['items']
},
computed: {
itemCount() {
return this.stores.cart.items.length
}
}
})
// JavaScript: use getComponent() for component access
wildflower.component('themed-panel', {
computed: {
themeClass() {
const theme = wildflower.getComponent('theme-manager')
return theme ? 'theme-' + theme.mode : 'theme-light'
}
}
})
- Bindings that read another entity via
$entity.path,subscribe, orgetComponent()register as dependents automatically - A change in the source updates only the bindings that read it
- Cross-entity cycles are detected and broken rather than looping forever
- Cleanup happens automatically when the dependent is destroyed
The one rule: always reach across entities through one of these tracking surfaces. If you capture a raw reference to another entity's state in a closure, you bypass the tracking proxy and the dependency is silently lost, so the update never fires. See Communication for the full set of cross-entity patterns.
Best Practices
✅ Do
- Use
$entity.pathin HTML for cross-entity binding - Use
subscribe+this.storesfor store access in JS - Rely on automatic dependency tracking
- Give list items a stable
idso rows are reused, not rebuilt - Use
data-renderfor content that should fully unmount
❌ Don't
- Capture another entity's raw state in a closure (bypasses tracking)
- Create circular dependencies between entities by design
- Re-create whole arrays when an in-place mutation would do
- Forget to remove non-reactive listeners you added by hand in
destroy()