Async Computed Properties v1.5+
A computed property may return a promise. Bindings keep showing the last good value while the request runs, then update when the new value arrives.
computed: block, same data-bind, same dependency tracking. As of v1.5 the restriction that a computed must return synchronously is gone.
Return a Promise
Write the computed the way you would write any derived value, and return the request. async functions work too, since an async function returns a promise:
<div data-component="async-user-card">
<p class="text-muted small mb-2">Each lookup below runs against a simulated 700ms request.</p>
<div class="mb-3">
<button class="btn btn-primary btn-sm me-2" data-action="loadUser" data-id="1">Ada</button>
<button class="btn btn-primary btn-sm me-2" data-action="loadUser" data-id="2">Grace</button>
<button class="btn btn-primary btn-sm" data-action="loadUser" data-id="3">Edsger</button>
</div>
<!-- No value yet means the first load is still running -->
<p class="text-muted" data-show="!user">Loading directory…</p>
<div class="card" data-show="user">
<div class="card-body">
<h5 class="card-title" data-bind="user.name"></h5>
<p class="card-text mb-1" data-bind="user.role"></p>
<p class="card-text text-muted mb-0" data-bind="user.quote"></p>
</div>
</div>
</div>
const TEAM = {
1: { name: 'Ada Lovelace', role: 'Analytical engines', quote: 'The engine weaves algebraic patterns.' },
2: { name: 'Grace Hopper', role: 'Compilers', quote: 'It is easier to ask forgiveness than permission.' },
3: { name: 'Edsger Dijkstra', role: 'Algorithms', quote: 'Simplicity is a great virtue.' }
}
// Stands in for fetch('/api/team/' + id).then(r => r.json())
function fetchMember(id) {
return new Promise(resolve => setTimeout(() => resolve(TEAM[id]), 700))
}
wildflower.component('async-user-card', {
state: { userId: 1 },
computed: {
// Returns a promise. The binding shows the previous member
// while the new request runs, then updates when it lands.
user() {
return fetchMember(this.userId)
}
},
loadUser(event) {
this.userId = Number(event.target.dataset.id)
}
})
Watch what happens when you switch members. The card keeps showing the current person for the ~700ms the new request takes, then swaps. No spinner flashes over real content, and nothing in the template had to account for the request at all.
First Load and Loading States
On the very first evaluation there is no previous value, so the computed reads undefined while the request runs. The absence of a value is the loading signal, and the syntax for it already exists:
<h1 data-bind="user.name"></h1>
<p data-show="!user">Loading…</p>
Once a value has arrived, a refresh keeps it on screen while the replacement is in flight, so !user stays false and the loading message never reappears over real data. A loading state that only shows when there is nothing better to show is the behavior you want on both counts, and it costs no new vocabulary.
Inputs Can Change Mid-Flight
When a dependency changes while a request is still running, the computed re-runs and launches a new request. The last call wins. An earlier response that arrives late is discarded, so bindings never go backwards:
<div data-component="async-search">
<input type="text" class="form-control mb-2" data-model="query"
placeholder="Search languages (try typing 'ru' quickly)">
<p class="text-muted" data-show="!results">Loading the index…</p>
<ul class="list-group" data-list="rows" data-key="name">
<template><li class="list-group-item py-1" data-bind="name"></li></template>
</ul>
</div>
const LANGUAGES = ['C', 'C++', 'Clojure', 'Elixir', 'Erlang', 'Go', 'Haskell',
'Java', 'JavaScript', 'Julia', 'Kotlin', 'Lisp', 'Lua', 'OCaml', 'Pascal',
'Perl', 'PHP', 'Prolog', 'Python', 'R', 'Ruby', 'Rust', 'Scala', 'SQL',
'Swift', 'TypeScript', 'Zig']
// Broad queries return more rows, so they take LONGER. Type 'ru' quickly:
// the 'r' request and the 'ru' request overlap, the 'ru' response arrives
// first, and the late 'r' response is discarded. The list always ends up
// matching what you typed last.
function searchLanguages(q) {
const hits = LANGUAGES
.filter(l => l.toLowerCase().includes(q.toLowerCase()))
.map(name => ({ name }))
const latency = 150 + hits.length * 25
return new Promise(resolve => setTimeout(() => resolve(hits), latency))
}
wildflower.component('async-search', {
state: { query: '' },
computed: {
// Async: re-runs on every keystroke, superseding the request in flight
results() {
return searchLanguages(this.query)
},
// Ordinary sync computed chained off the async one. Downstream
// computeds need no special handling.
rows() {
return this.results || []
}
}
})
The previous results stay on screen while you type, and the out-of-order responses sort themselves out. The component code contains no request bookkeeping, debounce, or cancellation logic.
What a Binding Sees, Moment by Moment
| Moment | What a binding sees |
|---|---|
| First evaluation, request in flight | undefined. Render a first-load state with data-show="!user". |
| Refresh in flight | The previous value, unchanged. |
| The request lands | The new value. Bindings update once. |
| A newer run started meanwhile | Nothing. The superseded response is discarded silently. |
| The promise rejects | undefined, and the entity's onError hook receives the error. |
Read Reactive Inputs Before the Request
Dependency tracking is synchronous. Reads that happen during the computed's own run are tracked; reads inside a .then() callback or after an await happen later, on nobody's watch. Read every reactive input up front, then build the request from locals:
computed: {
// ✅ this.userId is read synchronously, so changing it re-runs the computed
user() {
const id = this.userId
return fetch('/api/users/' + id).then(r => r.json())
},
// ✅ Same rule with async syntax: reads BEFORE the first await are tracked
async profile() {
const id = this.userId
const res = await fetch('/api/profiles/' + id)
return res.json()
},
// ❌ this.userId is read after the await, so it is never tracked and
// the computed will not re-run when it changes
async stale() {
await somethingElse()
return fetch('/api/users/' + this.userId).then(r => r.json())
}
}
This is the same rule that governs conditional reads in sync computeds. The tracker records what actually executes during evaluation, and an await ends that window.
Errors
A rejected promise puts the computed in the same errored state a thrown sync computed reaches. Bindings read undefined, and the entity's onError hook receives the rejection:
wildflower.component('user-panel', {
state: { userId: 1 },
computed: {
user() {
return fetch('/api/users/' + this.userId).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.json()
})
}
},
onError(error, context) {
// Every failure in this component arrives here, not just this
// computed's, so claim only the one this branch understands.
if (context.lifecycle === 'computed' && context.computedName === 'user') {
console.warn('user load failed', error)
return true // handled; stops propagation to parent boundaries
}
return false // everything else continues to a parent boundary
}
})
onError is the entity's one error hook, so init failures, action handlers, and every other computed report to the same function. Read context.lifecycle and context.computedName to identify what failed before handling it. Returning true marks the error handled and stops propagation; returning false passes it to a parent error boundary. Returning nothing at all counts as handled, so a handler that falls off the end swallows every error the component can raise. See Error Boundaries for the full contract.
Because the errored computed reads undefined, the same data-show="!user" element that covered the first load doubles as the empty state after a failure. The next successful run replaces it, so recovery needs no extra code either. A change to any dependency relaunches the request.
That hook only sees the newest request. When a dependency change relaunches while an earlier request is still in flight, the superseded request is discarded whole, so a late rejection from it never reaches onError, the same way a late resolution never reaches the binding. A failure of a request nobody is waiting on is not an application error.
Stores and Plugins Work the Same Way
Components, stores, and plugins share one computed implementation, so a store computed may return a promise too. This is often the better home for a request several components read:
wildflower.store('directory', {
state: {},
computed: {
members() {
return fetch('/api/team').then(r => r.json())
}
}
})
wildflower.component('team-list', {
subscribe: ['directory'],
computed: {
rows() {
return this.stores.directory.members || []
}
}
})
The store owns the request; every subscribed component reads the resolved value through ordinary computed chaining. One fetch serves the page.
Item-Level Computeds Stay Synchronous
A computed that takes the item as a parameter, like label(item), runs once per list row through the list renderer rather than through the entity's reactive graph. That per-row path does no async tracking, so a promise returned there would bind as text, and a list of a thousand rows would issue a thousand uncoordinated requests. Dev builds warn with WF-235 when an item-level computed returns a promise.
computed: {
// ❌ One request per row; dev builds warn (WF-235)
avatar(item) {
return fetch('/api/avatars/' + item.id).then(r => r.json())
},
// ✅ One request for the collection
avatars() {
return fetch('/api/avatars?ids=' + this.state.items.map(i => i.id).join(','))
.then(r => r.json())
},
// ✅ Derive the row array from the landed collection, and bind the
// list to it: data-list="rows"
rows() {
const all = this.avatars || {}
return this.state.items.map(i => ({ ...i, avatar: all[i.id] || null }))
}
}
When avatars resolves, rows recomputes through ordinary chaining and the keyed list reconciler updates the rows in place. The live search demo above uses the same shape.
How It Works
When a computed returns a promise, the framework holds the node at its previous value, remembers which run launched the request, and attaches a continuation. When the promise settles, the settled value wakes the computed's observers through the same dependency tracking every other change uses. The computed's body does not re-run on resolution, so a fetch never re-triggers itself, and a response from a superseded run fails its generation check and is dropped. The reactive graph itself never learns async exists, which is why everything downstream, from chained computeds to data-list, keeps working unmodified.
Best Practices
✅ Do
- Return the promise itself (or use an
asyncfunction) - Read every reactive input before the first
awaitor.then() - Use
data-show="!value"for the first-load state - Put shared requests in a store computed
- Handle rejections in
onError
❌ Don't
- Write state from inside the computed; return the value instead
- Return promises from item-level computeds (WF-235)
- Add manual cancellation or debounce for correctness; supersession already guarantees the last call wins
- Invent a loading flag in state; the absence of the value is the signal