Query Lifecycle
Every query in a Query Pool has a small, predictable lifecycle.
The query's status describes the outcome or current phase of execution, while reactive fields such as data, error, loading, streaming, streamed, and chunks provide more detailed state for the UI.
This guide explains:
- lifecycle statuses and transitions
- reactive fields and their semantics
- starting and completing executions
- success and error handling
- cancellation and superseded runs
cancel()vsreset()- streaming lifecycle
- cache short-circuiting
- initial execution after registration
- how mutation lifecycle relates to query lifecycle
For creating queries and using fetch() / refresh(), see Queries.
For dependency execution and in-flight reuse, see Query Dependencies.
For abort behavior, see Query Cancellation.
Lifecycle Status
A query's status is one of five values:
| Status | Meaning |
|---|---|
"idle" | The query has not completed an execution, or it has been reset. |
"loading" | An execution is currently in progress. |
"success" | The latest completed execution succeeded. |
"error" | The latest completed execution failed. |
"cancelled" | The current execution was explicitly cancelled while loading. |
const users = pool.query("users", {
source: fetchUsers,
});
console.log(users.status);
// "idle" initially, then "loading", then "success" or "error"The query handle is returned synchronously. Its initial execution starts asynchronously, so the handle can be observed immediately even though execution has not yet completed.
status is reactive. Reading it inside an effect, computed value, or template establishes a dependency on that field.
Reactive Fields
A query exposes several reactive fields that describe its current execution state:
| Field | Meaning |
|---|---|
data | The latest successful result. Previous data is preserved across errors and cancellation. |
error | The latest execution error, or null when there is no current error. |
loading | true while the query is executing. |
streaming | true while a streaming worker execution is receiving chunks. |
streamed | true after at least one chunk has been received during the current run. |
chunks | Chunks received during the current streaming run. |
status | Current lifecycle status. |
The fields are independent but coordinated.
For example, a query can have:
status = "error"loading = falsedata = previous successful resulterror = current error
This is intentional. A failed refresh does not require the UI to discard data that was already successfully loaded.
Reactive UI Example
const message = computed(() => {
if (users.loading) {
return "Loading users...";
}
if (users.error) {
return "Unable to load users.";
}
if (users.status === "cancelled") {
return "Loading was cancelled.";
}
return `${(users.data ?? []).length} users`;
});There is normally no need to copy loading, error, or status into component state.
The query handle itself is reactive.
The Basic Lifecycle
The normal lifecycle is:
┌───────────┐
│ idle │
└─────┬─────┘
│
start execution
│
▼
┌───────────┐
│ loading │
└─────┬─────┘
│
┌──────────┼───────────┐
│ │ │
success error cancel()
│ │ │
▼ ▼ ▼
┌─────────┐ ┌───────┐ ┌───────────┐
│ success │ │ error │ │ cancelled │
└────┬────┘ └───┬───┘ └─────┬─────┘
│ │ │
└──────────┼───────────┘
│
reset()
│
▼
┌───────────┐
│ idle │
└───────────┘A new execution from success, error, or cancelled goes through "loading" again.
reset() is the operation that returns the query to "idle" while clearing its execution state.
Starting an Execution
A query can start execution through several paths:
- initial registration with
pool.query() query.fetch()query.refresh()pool.refresh(key)- dependency-plan execution
- a forced execution
At the beginning of an actual run, the query establishes a new execution identity and prepares its reactive state.
Conceptually:
start run
│
├── supersede previous run
├── create AbortController
├── loading = true
├── status = "loading"
├── error = null
├── chunks = []
└── streamed = false
│
▼
source / moduleThe query uses an internal execution ID so that results belonging to an older run cannot overwrite the state of a newer run.
Example
const users = pool.query("users", {
source: async (signal) => {
const response = await fetch("/api/users", {
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
},
});Once execution starts:
users.loading; // true
users.status; // "loading"
users.error; // nullWhen the execution completes successfully:
users.loading; // false
users.status; // "success"
users.data; // fetched usersIn-Flight Deduplication
Starting an execution does not necessarily mean starting new asynchronous work.
If the query already has an active execution and the new execution is not forced, the existing in-flight work can be reused.
Conceptually:
request A
│
▼
┌───────────────┐
│ active promise│
└───────┬───────┘
│
├──────── request B
│ │
│ ▼
│ reuse same work
│
▼
resolveThis prevents multiple callers from unnecessarily executing the same query concurrently.
For example:
const first = users.fetch();
const second = users.fetch();
await Promise.all([first, second]);The pool can reuse the active execution rather than treating both calls as independent pieces of work.
Use force when a new execution must supersede the existing in-flight work.
See Query Dependencies for the complete execution and deduplication model.
Success
When the current executor resolves successfully, the query commits the result.
The lifecycle is:
- The result is accepted only if the execution is still current.
- The cache is updated when caching is configured.
datais replaced with the successful result.errorbecomesnull.loadingbecomesfalse.streamingbecomesfalse.statusbecomes"success".- The execution's abort controller is released.
loading
│
│ executor resolves
▼
data = result
error = null
loading = false
streaming = false
status = "success"Previous Data
A successful execution replaces the previous data:
// Previous
users.data;
// [{ id: 1, name: "Ada" }]
// Successful refresh
await users.refresh();
// New
users.data;
// [{ id: 1, name: "Ada" }, { id: 2, name: "Grace" }]The important rule is that only a successful result replaces query data during normal execution.
Error
If the current execution rejects and has not been superseded or cancelled:
loadingbecomesfalse.streamingbecomesfalse.erroris set to the thrown value.statusbecomes"error".- Existing data is preserved.
loading
│
│ executor rejects
▼
error = thrown value
loading = false
streaming = false
status = "error"
data = previous valueKeeping Previous Data
This allows a refresh failure to preserve useful UI data:
const users = pool.query("users", {
source: fetchUsers,
});Suppose the first execution succeeds:
users.data;
// [{ id: 1, name: "Ada" }]A later refresh fails:
await users.refresh();The resulting state can be:
users.status; // "error"
users.error; // Error(...)
users.data; // [{ id: 1, name: "Ada" }]This is useful for interfaces that should display stale-but-known data together with an error indicator rather than replacing the entire view with an empty state.
Dependency Errors
When a dependency fails, dependent execution is represented using QueryDependencyError so the execution plan can preserve the root query's original error where appropriate.
See Query Dependencies.
Cancellation
Calling cancel() explicitly stops the current execution.
The cancellation path:
- Supersedes the current execution id.
- Aborts the current
AbortController. - Clears the in-flight execution.
- Sets
loading = false. - Sets
streaming = false. - Sets
status = "cancelled"when the query was loading. - Preserves existing data.
loading
│
│ cancel()
▼
┌─────────────────┐
│ cancelled │
│ │
│ loading=false │
│ streaming=false |
│ data preserved │
└─────────────────┘Example
const users = pool.query("users", {
source: async (signal) => {
const response = await fetch("/api/users", {
signal,
});
return response.json();
},
});
const promise = users.fetch();
users.cancel();
console.log(users.loading); // false
console.log(users.status); // "cancelled"
await promise;The underlying source should always use the supplied signal when the operation supports cancellation:
source: async (signal) => {
const response = await fetch("/api/users", {
signal,
});
return response.json();
}See Query Cancellation.
Superseded Executions
A query can also have an execution superseded by a newer execution.
This is different from an explicit cancel().
For example:
await users.fetch({
force: true,
});If an older execution is still active, the newer execution becomes the current execution. The older execution's eventual result is ignored.
The important invariant is:
Only the current execution may commit query state.
This prevents a slower, older request from overwriting the result of a newer request.
Execution A
│
│ running
│
├───────────────┐
│ │
▼ │
Execution B │
(force) │
│ │
▼ │
current execution │
│ │
▼ │
commit result │
│
Execution A resolves later
│
▼
ignored as staleA forced execution therefore provides latest-run-wins protection rather than exposing the superseded execution as a separate visible lifecycle.
Reset
reset() is stronger than cancel().
It cancels active work and clears the query's accumulated execution state.
reset():
- Cancels any in-flight execution.
- Clears cached query data.
- Clears cached input.
- Clears
data. - Clears
error. - Clears
chunks. - Sets
loading = false. - Sets
streaming = false. - Sets
streamed = false. - Sets
status = "idle".
cancel()
│
├── stop execution
├── preserve data
└── status → "cancelled"
reset()
│
├── stop execution
├── clear data
├── clear cache
├── clear input
├── clear chunks
├── clear error
└── status → "idle"When to Use Each
Use cancel() when:
"Stop this work, but keep the last successful result."
Use reset() when:
"Forget the query's current state and make it behave as though it has not run."
For example, when leaving a feature completely:
users.reset();
console.log(users.status); // "idle"
console.log(users.data); // undefined
console.log(users.error); // nullStreaming Lifecycle
Streaming applies to worker-module queries configured with:
stream: trueA streaming query has two related concepts:
loading— the complete execution is still active.streaming— the worker stream is currently open.
When a stream starts:
query.loading; // true
query.streaming; // true
query.streamed; // false
query.chunks; // []When the first chunk arrives:
query.streamed; // true
query.chunks; // [firstChunk]Additional chunks accumulate:
execution starts
│
▼
loading = true
streaming = true
streamed = false
chunks = []
│
├── chunk ──► chunks
│ streamed = true
│
├── chunk ──► chunks
│
├── chunk ──► chunks
│
▼
stream ends
│
▼
streaming = false
│
▼
execution completes
│
▼
success / error / cancelledExample
pool.registerModule("generateReport", {
url: new URL(
"./workers/generate-report.js",
import.meta.url,
).href,
});
const report = pool.query("report", {
module: "generateReport",
stream: true,
});A UI can react to the accumulated chunks:
const progress = computed(() => {
if (!report.loading) {
return "Finished";
}
return `${report.chunks.length} chunks received`;
});A new execution clears the chunks from the previous run:
new run
│
├── chunks = []
└── streamed = falseChunks belonging to an older, superseded execution are ignored.
See Query Pool and Workers and Transferable Data.
Cache and Lifecycle
Caching can cause an execution to complete without calling the query's source or worker module.
When a configured cache entry is still fresh and the execution is not forced:
query execution
│
▼
cache available?
/ \
no yes
│ │
▼ ▼
execute cache fresh?
/ \
no yes
│ │
▼ ▼
execute use cached
│
▼
status = successA fresh cache hit can:
- restore
datafrom the cache - clear
error - set
loading = false - set
streaming = false - set
status = "success" - avoid calling
sourceor the worker module
For example:
const users = pool.query("users", {
source: fetchUsers,
cache: {
ttl: 60_000,
},
});If the cached result is still fresh:
await users.refresh();the query can reuse the cached result instead of executing fetchUsers() again.
Invalidating Cache
invalidate() does not execute the query.
It only marks the cache entry stale:
users.invalidate();
console.log(users.status);
// Current lifecycle status is unchanged.A later execution that needs fresh data will execute the query again.
This distinction is important:
invalidate()
│
▼
cache becomes stale
│
│ no execution yet
▼
later fetch / refresh
│
▼
query executesSee Caching and Invalidation.
Initial Registration
Registering a query returns its handle synchronously:
const users = pool.query("users", {
source: fetchUsers,
});The registration call itself does not require awaiting the initial execution.
Conceptually:
pool.query("users", definition)
│
├──────────────► returns handle
│ status = "idle"
│
└──────────────► starts initial plan asynchronously
│
▼
loading
│
┌─────┴─────┐
▼ ▼
success errorThe initial plan includes dependencies when the query has dependsOn.
For example:
const profile = pool.query("profile", {
dependsOn: ["session"],
source: async () => {
return fetchProfile(pool.data("session"));
},
});The dependency plan executes upstream work before the dependent query.
Registration Errors
Failures from the initial execution plan are handled asynchronously rather than causing pool.query() itself to reject.
Therefore:
const users = pool.query("users", {
source: async () => {
throw new Error("Network failure");
},
});still returns a query handle.
The eventual failure is represented through:
users.status; // "error"
users.error; // Error("Network failure")This makes query registration synchronous while execution remains asynchronous.
Query Data Across Lifecycle Events
The most important data-preservation rule is:
Errors and cancellation do not normally erase the last successful data.
Consider:
successful request
│
▼
data = [users]
status = "success"
│
│ refresh
▼
status = "loading"
│
├── success ──► data replaced
│
├── error ────► data preserved
│
└── cancel ───► data preservedThis makes stale-data interfaces straightforward:
const users = pool.query("users", {
source: fetchUsers,
});
const view = computed(() => {
if (users.loading && !users.data) {
return "Loading...";
}
if (users.error && users.data) {
return "Showing previous data — refresh failed.";
}
if (users.error) {
return "Unable to load users.";
}
return `Users: ${users.data?.length ?? 0}`;
});The UI can therefore distinguish:
- no data yet
- loading with previous data
- successful data
- previous data with an error
- cancelled execution
without maintaining duplicate request state.
Reactive Lifecycle Example
A query's lifecycle fields are ordinary reactive state from the perspective of consumers.
const users = pool.query("users", {
source: fetchUsers,
});
effect(() => {
if (users.loading) {
console.log("Loading...");
return;
}
if (users.error) {
console.error("Request failed:", users.error);
return;
}
if (users.status === "success") {
console.log("Loaded:", users.data);
}
});Only the reactive fields read by the computation become dependencies.
For example:
const label = computed(() => {
if (users.loading) {
return "Loading...";
}
return `Users: ${users.data?.length ?? 0}`;
});This computation depends on loading and data. It does not need to react to unrelated query fields simply because they changed.
Mutation Lifecycle
Mutations use the same core status vocabulary:
idleloadingsuccesserrorcancelled
They also expose lifecycle fields such as:
datavariableserrorloadingstreamingstreamedchunksstatus
The major difference is how execution begins.
Queries can start during registration or through fetch() / refresh():
const users = pool.query("users", {
source: fetchUsers,
});Mutations begin explicitly through mutate():
const createUser = pool.mutation("createUser", {
execute: async (input, { signal }) => {
const response = await fetch("/api/users", {
method: "POST",
body: JSON.stringify(input),
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
},
});
await createUser.mutate({
name: "Ada",
});Mutation lifecycle also includes hooks such as:
onMutateonSuccessonError
and may invalidate or refresh queries after a successful mutation.
The mutation's own lifecycle is independent from the lifecycle of queries it invalidates.
For example:
mutation
│
▼
loading
│
▼
success
│
├── invalidate users
│
└── refresh users
│
▼
users: loading
│
┌────┴────┐
▼ ▼
success errorA successful mutation does not mean that every invalidated query has also succeeded.
See Mutations and Invalidation.
Lifecycle Summary
The query lifecycle can be summarized as:
registration
│
▼
┌─────────┐
│ idle │
└────┬────┘
│
start execution
│
▼
┌─────────┐
│ loading │
└────┬────┘
│
┌───────────┼────────────┐
│ │ │
success error cancel / abort
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌───────────┐
│ success │ │ error │ │ cancelled │
└────┬────┘ └────┬────┘ └─────┬─────┘
│ │ │
└───────────┼────────────┘
│
new execution
│
▼
┌─────────┐
│ loading │
└─────────┘
reset() from any lifecycle state
│
▼
┌─────────┐
│ idle │
└─────────┘The practical rules are:
"idle"means the query has no completed execution or has been reset."loading"means the current execution is active."success"means the latest completed execution succeeded."error"means the latest completed execution failed."cancelled"represents an explicitly cancelled loading execution.datasurvives errors and cancellation.reset()clears data, cache, input, and lifecycle state.- Only the current execution may commit results.
- Fresh cache can satisfy execution without calling the source/module.
- Streaming adds chunk-level state without changing the core lifecycle model.