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.

đź’ˇ Key Concept: There is nothing new to learn here. Same 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)
    }
})
Live Preview

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 || []
        }
    }
})
Live Preview

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 async function)
  • Read every reactive input before the first await or .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
đź’ˇ When to reach for data queries instead: An async computed derives one value from reactive inputs. When you need freshness policies, retry, optimistic writes, or list reconciliation on top of the fetch, that lifecycle machinery lives in Data Queries.