Vue / Intermediate: feature state and effect ownership / Lesson 10
Asynchronous data and obsolete results
Coordinate loading, cancellation, and state publication so an older operation cannot silently replace the current task.
On this page
Lesson goals
- Model loading, content, failure, and invalidation separately.
- Use cleanup and result identity instead of timing guesses.
Define what an asynchronous outcome means
A data operation can be idle, pending, successful, failed, or obsolete relative to the userโs current task. A single loading boolean rarely describes all useful cases, especially when stale content remains visible during refresh. Define which state is authoritative, how errors are shown, and what retry means. Do not start requests as an accidental consequence of a computed getter or every render. Use an explicit owner and dependency such as a route id or search query. A successful promise does not guarantee its result still belongs to the current screen or account when it resolves.
Invalidate work before accepting a result
The watcher below treats query as the operation identity and registers cleanup before awaiting the injected loader. The active flag prevents publication after invalidation, while the AbortController lets a compatible loader stop unnecessary work. Cancellation support depends on the loader implementation and is not a replacement for the publication guard. The service import is an application contract, not a real endpoint. This course does not send a request. A real loader also validates response data and maps failures without turning expected cancellation into a misleading user error.
import { ref, watch } from "vue"
import { loadNotes } from "./notes-service.js"
const query = ref("")
const state = ref({ status: "idle", notes: [], error: null })
watch(query, async (value, _previous, onCleanup) => {
let active = true
const controller = new AbortController()
onCleanup(() => { active = false; controller.abort() })
state.value = { ...state.value, status: "loading", error: null }
try {
const notes = await loadNotes(value, { signal: controller.signal })
if (active) state.value = { status: "ready", notes, error: null }
} catch (error) {
if (active) state.value = { ...state.value, status: "failed", error }
}
})
Use in managed synchronous setup with a compatible loadNotes contract. Cleanup runs when the watcher is invalidated or stopped. The snippet is incomplete without that service and Vue context, and no network or framework execution is claimed.
Protect ordering as well as cancellation
Two operations can finish in the opposite order from their start times. Aborting a read can save resources, but an operation may already have completed or may ignore the signal. A generation token or equivalent current-task identity protects state publication independently. Account scope and route identity belong in that policy when they affect ownership. For writes, cancellation cannot undo a server-side action that was already accepted; use an appropriate durable operation and idempotency contract. Separate read-result freshness from write consistency rather than assuming AbortController solves every concurrency problem.
Test reversed completion without a network
The pure coordinator gives each started task a token and accepts only the current token. The example deliberately publishes the second task before the first and verifies the first is rejected. It simulates the publication decision, not transport or Vue watcher behavior. This makes the race policy reproducible with no timers or remote service. A component integration test should separately verify that query changes allocate the right identity, cleanup happens, and the rendered status follows accepted state. Keep the original failure evidence when investigating timing bugs instead of accepting one convenient successful retry.
function latestCoordinator() {
let generation = 0
let current = null
return {
begin: () => ++generation,
publish(token, value) {
if (token !== generation) return false
current = value
return true
},
read: () => current
}
}
const coordinator = latestCoordinator()
const first = coordinator.begin()
const second = coordinator.begin()
if (!coordinator.publish(second, "new") || coordinator.publish(first, "old")) {
throw new Error("STALE_RESULT_ACCEPTED")
}
if (coordinator.read() !== "new") throw new Error("CURRENT_RESULT_MISMATCH")
Runs under Node using local values only. The accepted value remains new after the old publication attempt. The test does not validate abort semantics, HTTP behavior, or component rendering.
Validate data and expose recoverable failure
A transport success can still contain invalid or unauthorized data. Validate at the service boundary and preserve a useful domain error rather than exposing raw private payloads. Retry should have a defined target and avoid duplicate submissions. Decide whether the current content stays visible after refresh failure and communicate freshness honestly. Keep request work tied to the intended owner, and review watchers created asynchronously or outside component setup for explicit disposal. The baseline supports onWatcherCleanup in Vue 3.5, but its registration must be synchronous; the callback cleanup used here makes the lifecycle visible without relying on code after await.
Common errors and fixes
Old search results replace a newer query
- Why it happens
- State publication has no current-task identity check.
- How to fix it
- Invalidate obsolete work and guard accepted result identity.
- Prevent it next time
- Test reversed completion order deterministically.
Cancellation appears as an ordinary failure
- Why it happens
- Expected invalidation is mapped to a user error.
- How to fix it
- Separate obsolete work from active-operation errors.
- Prevent it next time
- Include cancellation in the state contract.
Cleanup is registered after await incorrectly
- Why it happens
- A synchronous cleanup API is used outside its allowed phase.
- How to fix it
- Register invalidation before awaiting and follow the selected API contract.
- Prevent it next time
- Review version-specific watcher cleanup rules.
Check your understanding
1. Why keep a publication guard when the loader supports abort?
Show worked solution
Abort may race with completion or be ignored by an implementation. The guard separately establishes that the result belongs to the current owner and task before state is changed. Both controls solve related but different problems.
2. A save request is canceled after the server accepts it. Is retrying with a fresh operation identity safe?
Show worked solution
Not automatically. The first write may already exist, so a new identity can duplicate the operation. Use the backendโs documented consistency and idempotency contract and preserve ambiguous outcomes explicitly.
Key takeaways
- Async state needs explicit freshness and failure semantics.
- Cancellation and publication identity are complementary.
- Test races with controlled outcomes rather than sleeps.