Mutations
Mutations represent asynchronous write operations in the Query Pool. They are designed for operations that change server-side or externally owned data, such as creating, updating, or deleting records.
A mutation owns the lifecycle of the write itself:
- input variables
- execution state
- result data
- errors
- cancellation
- optional streaming
- optimistic updates
- rollback
- success and error hooks
- query invalidation
Unlike queries, mutations do not automatically execute when registered. A mutation starts when mutate() is called.
For asynchronous reads, see Queries. For the mutation lifecycle model, see Query Lifecycle. For the queries affected after a successful mutation, see Invalidation.
The Mutation Model
A mutation is registered under a unique string key:
const createUser = pool.mutation("createUser", {
execute: async (input, { signal }) => {
const res = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
});Registration is synchronous:
const createUser = pool.mutation("createUser", {
execute: createUserRequest,
});
console.log(createUser.status); // "idle"Unlike pool.query(), registration does not start an execution.
The mutation remains idle until:
await createUser.mutate({
name: "Ada",
});The basic lifecycle is:
mutation registration
│
▼
idle
│
│ mutate(input)
▼
loading
/ \
/ \
success error
│ │
└────┬─────┘
│
next mutate()
│
▼
loadingcancel() can stop a loading mutation without discarding its previous successful data.
Registering a Mutation
A mutation uses either a local execute function or a worker module.
Local mutation
const updateUser = pool.mutation("updateUser", {
execute: async (input, { signal }) => {
const res = await fetch(`/api/users/${input.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
});The execute function receives:
- the input passed to
mutate() - an execution context containing the
AbortSignal
await updateUser.mutate({
id: 1,
name: "Ada",
});The execution pipeline is:
mutate(input)
│
▼
execute(input, { signal })
│
▼
result
│
▼
mutation.dataWorker mutation
A mutation can instead reference a registered worker module:
const pool = createQueryPool({
worker: {
enabled: true,
},
});
pool.registerModule("saveUser", {
url: new URL("./workers/save-user.js", import.meta.url).href,
});
const saveUser = pool.mutation("saveUser", {
module: "saveUser",
});Worker-backed mutations execute through the Query Pool worker architecture.
See Query Registry and Query Pool and Workers.
A mutation definition uses the local execute path or the worker module path; they are not combined for the same mutation.
Running a Mutation
Call mutate(input) to execute the mutation:
const createUser = pool.mutation("createUser", {
execute: async (input, { signal }) => {
const res = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
});
const user = await createUser.mutate({
name: "Ada",
email: "[email protected]",
});mutate() returns a promise for the mutation execution.
The mutation handle itself remains reactive:
console.log(createUser.loading);
console.log(createUser.status);
console.log(createUser.data);
console.log(createUser.error);This makes it possible to use the mutation directly from component templates, effects, or computed values without copying its lifecycle into component state.
Mutation State
A mutation exposes reactive state describing its most recent execution.
| Field | Description |
|---|---|
data | Latest successful mutation result. |
variables | Input passed to the most recent mutate(). |
error | Latest execution error, if any. |
loading | true while a mutation is executing. |
status | idle, loading, success, error, or cancelled. |
chunks | Streamed chunks accumulated during the current run. |
streaming | Whether a worker stream is currently open. |
streamed | Whether a chunk has been received during the current run. |
For example:
const status = computed(() => {
if (createUser.loading) {
return "Creating user…";
}
if (createUser.error) {
return "Could not create user";
}
if (createUser.status === "success") {
return "User created";
}
return "Ready";
});Reading these fields from an effect, computed value, or template establishes a reactive dependency on the corresponding mutation state.
Variables
variables contains the input from the most recent call to mutate().
const updateUser = pool.mutation("updateUser", {
execute: async (input, { signal }) => {
// ...
},
});
await updateUser.mutate({
id: 42,
name: "Ada",
});
console.log(updateUser.variables);
// { id: 42, name: "Ada" }This is useful when the UI needs to reflect the operation currently being performed:
const message = computed(() => {
if (!updateUser.loading) {
return "";
}
return `Updating user ${updateUser.variables?.id}…`;
});variables represents mutation input. It should not be confused with data, which represents the successful result returned by the mutation.
Mutation Status
Mutations use the same lifecycle vocabulary as queries:
| Status | Meaning |
|---|---|
idle | No mutation execution has completed yet. |
loading | A mutation is currently executing. |
success | The latest mutation completed successfully. |
error | The latest mutation failed. |
cancelled | The latest loading mutation was cancelled. |
A mutation starts in idle:
const deleteUser = pool.mutation("deleteUser", {
execute: deleteUserRequest,
});
console.log(deleteUser.status); // "idle"Calling mutate() transitions it to loading:
const promise = deleteUser.mutate({ id: 42 });
console.log(deleteUser.loading); // true
console.log(deleteUser.status); // "loading"
await promise;
console.log(deleteUser.status); // "success" or "error"A subsequent mutate() starts another execution.
Execution Context
Local mutation execute functions receive an execution context:
execute: async (input, { signal }) => {
// ...
}The signal is the mutation's AbortSignal.
Always pass it to cancellable APIs:
execute: async (input, { signal }) => {
const response = await fetch("/api/users", {
method: "POST",
body: JSON.stringify(input),
signal,
});
return response.json();
}This allows:
mutation.cancel();to propagate cancellation to the underlying request.
See Query Cancellation for the complete cancellation model.
Optimistic Updates
Mutations can update query data before the server confirms the write.
This is useful when the expected result is known immediately and the UI should respond without waiting for the network request.
The onMutate hook runs before the mutation body:
const createUser = pool.mutation("createUser", {
execute: async (input, { signal }) => {
const res = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
onMutate(input, ctx) {
const previous = ctx.getQueryData("users") ?? [];
ctx.setQueryData("users", [
...previous,
{
id: `temp-${Date.now()}`,
...input,
},
]);
return {
previous,
};
},
onError(error, input, ctx) {
if (ctx.previous) {
ctx.setQueryData("users", ctx.previous);
}
},
invalidates: ["users"],
});The flow is:
mutate(input)
│
▼
onMutate()
│
├── read previous query data
│
├── optimistic setQueryData()
│
▼
execute()
│
┌──────┴──────┐
│ │
success error
│ │
▼ ▼
invalidate onError()
rollbackOptimistic updates are therefore query-data updates, not a separate mutation state store.
Mutation Context
Mutation hooks receive a context containing the Query Pool operations needed to coordinate related query data.
The context provides:
| Method / property | Purpose |
|---|---|
pool | Public Query Pool API. |
getQueryData(key) | Read current data for a query. |
setQueryData(key, value | fn) | Write query data without executing the query. |
For example:
onMutate(input, ctx) {
const previous = ctx.getQueryData("users");
ctx.setQueryData("users", (users = []) => [
...users,
{
id: "temporary",
...input,
},
]);
return {
previous,
};
}The value returned from onMutate() becomes part of the mutation context available to later hooks.
That is what makes rollback possible:
onMutate(input, ctx) {
const previous = ctx.getQueryData("users");
ctx.setQueryData("users", optimisticUsers);
return { previous };
},
onError(error, input, ctx) {
ctx.setQueryData("users", ctx.previous);
},The important distinction is:
onMutate()
│
└── returns rollback information
│
▼
mutation context
│
┌─────┴─────┐
▼ ▼
onError() onSuccess()Mutation Hooks
Mutations support lifecycle hooks around execution.
onMutate
onMutate(input, ctx) runs before the mutation executes.
It is commonly used for:
- optimistic updates
- capturing previous query data
- preparing rollback information
onMutate(input, ctx) {
const previous = ctx.getQueryData("users");
ctx.setQueryData("users", nextUsers);
return { previous };
}onSuccess
onSuccess(result, input, ctx) runs after a successful mutation.
onSuccess(result, input, ctx) {
console.log("Created:", result);
}It can be used for additional post-success work before invalidation processing.
onError
onError(error, input, ctx) runs when the mutation fails.
onError(error, input, ctx) {
console.error("Mutation failed:", error);
if (ctx.previous) {
ctx.setQueryData("users", ctx.previous);
}
}A common optimistic-update pattern is therefore:
const updatePost = pool.mutation("updatePost", {
execute: updatePostRequest,
onMutate(input, ctx) {
const previous = ctx.getQueryData("posts");
ctx.setQueryData("posts", posts => {
return posts.map(post =>
post.id === input.id
? { ...post, ...input }
: post
);
});
return { previous };
},
onError(error, input, ctx) {
ctx.setQueryData("posts", ctx.previous);
},
onSuccess(result) {
console.log("Server confirmed:", result);
},
});Query Data Updates
setQueryData() changes query data without executing its source or worker module.
This makes it appropriate for optimistic mutations:
const previous = ctx.getQueryData("users");
ctx.setQueryData("users", [
...previous,
optimisticUser,
]);It can also accept an updater function:
ctx.setQueryData("users", users => {
return users.map(user =>
user.id === updatedUser.id
? updatedUser
: user
);
});The update is reactive. Components consuming users.data therefore react to the change immediately.
mutation
│
▼
setQueryData()
│
▼
query.data changes
│
▼
reactive consumers updatesetQueryData() does not execute the query's source.
If the server must be contacted, the mutation's execute or module performs that work.
Rollback
A rollback normally consists of:
- capturing the previous query data in
onMutate - applying an optimistic value
- restoring the previous value in
onError
const updatePost = pool.mutation("updatePost", {
execute: async (input, { signal }) => {
const res = await fetch(`/api/posts/${input.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
onMutate(input, ctx) {
const previous = ctx.getQueryData("posts");
ctx.setQueryData("posts", posts =>
posts.map(post =>
post.id === input.id
? { ...post, ...input }
: post
)
);
return { previous };
},
onError(error, input, ctx) {
ctx.setQueryData("posts", ctx.previous);
},
invalidates: ["posts"],
});The complete state flow is:
mutate()
│
▼
onMutate()
│
optimistic data
│
▼
execute
/ \
/ \
success error
│ │
│ ▼
│ rollback
│ │
└─────┬─────┘
▼
invalidation
(on success)A successful mutation normally invalidates the affected queries so the authoritative server state can replace the optimistic value.
Invalidating Queries
Mutations can declare the queries they invalidate:
const createUser = pool.mutation("createUser", {
execute: createUserRequest,
invalidates: ["users"],
});After a successful mutation, the listed query is invalidated and refreshed.
This separates two responsibilities:
Mutation
│
├── perform write
│
└── invalidates: ["users"]
│
▼
users
│
▼
refreshFor more advanced dependency graphs, an invalidation entry can specify whether dependents should also participate:
invalidates: [
{
key: "postDetail",
dependents: true,
force: false,
},
],See Invalidation for the complete invalidation model.
Invalidation Options
A mutation's invalidates list can contain query keys or invalidation descriptors.
Simple form:
invalidates: [
"users",
"posts",
],Descriptor form:
invalidates: [
{
key: "users",
dependents: true,
force: false,
},
],The options control how the affected query graph is refreshed.
| Option | Purpose |
|---|---|
key | Query to invalidate. |
dependents | Whether reverse dependents should also be scheduled. |
force | Whether the refresh should bypass reusable in-flight/fresh execution where applicable. |
For example:
invalidates: [
{
key: "users",
dependents: true,
force: true,
},
],can refresh users and its dependent queries using a forced execution.
Mutation Execution Options
mutate() accepts execution options:
await createUser.mutate(input, {
force: true,
});The supported options include:
| Option | Purpose |
|---|---|
transfer | Enable Transferable Object transport for worker execution. |
force | Force invalidation refreshes where applicable. |
skipInvalidation | Skip the mutation's configured invalidation processing. |
awaitInvalidations | Wait for invalidation refreshes before the mutation promise resolves; defaults to true. |
These options affect the execution of that particular mutation call rather than changing the mutation definition permanently.
Skipping Invalidation
A mutation can have configured invalidations while an individual execution skips them:
const updateUser = pool.mutation("updateUser", {
execute: updateUserRequest,
invalidates: ["users"],
});
await updateUser.mutate(input, {
skipInvalidation: true,
});The mutation itself still executes normally. Only the configured invalidation phase is skipped.
This can be useful when the caller already knows that the affected query will be refreshed separately.
Awaiting Invalidations
By default, mutation execution waits for its configured invalidation processing:
await createUser.mutate(input);awaitInvalidations can be disabled for callers that do not need to wait for those refreshes:
await createUser.mutate(input, {
awaitInvalidations: false,
});The distinction is:
mutate()
│
▼
execute mutation
│
▼
success
│
▼
invalidate queries
│
├── awaitInvalidations: true
│ │
│ ▼
│ mutation promise waits
│
└── awaitInvalidations: false
│
▼
mutation can resolve
without waitingThe invalidation work itself remains separate from the mutation's own execution lifecycle.
Cancellation
A mutation can be cancelled while it is loading:
const saveUser = pool.mutation("saveUser", {
execute: async (input, { signal }) => {
const res = await fetch("/api/users", {
method: "POST",
body: JSON.stringify(input),
signal,
});
return res.json();
},
});
const request = saveUser.mutate({
name: "Ada",
});
saveUser.cancel();
await request.catch(() => {});Calling cancel():
- aborts the mutation's
AbortController - supersedes the current execution
- clears the in-flight execution
- changes status to
"cancelled"when the mutation was loading - preserves existing successful data
The underlying operation can only respond promptly to cancellation if it uses the provided signal.
execute: async (input, { signal }) => {
return fetch("/api/users", {
method: "POST",
body: JSON.stringify(input),
signal,
});
}See Query Cancellation.
Resetting a Mutation
reset() clears the mutation's execution state:
createUser.reset();It returns the mutation to idle and clears its current state.
Conceptually:
loading
│
│ reset()
▼
idleThis differs from cancel():
cancel()
│
├── stop current work
├── preserve previous data
└── status → cancelled
reset()
│
├── stop current work
├── clear mutation state
└── status → idleUse cancel() when the operation should stop but its existing successful result should remain available.
Use reset() when the mutation should return to its initial state.
Streaming Mutations
Worker-backed mutations can use streaming when the module definition enables it.
const generateReport = pool.mutation("generateReport", {
module: "generateReport",
stream: true,
});During execution:
console.log(generateReport.loading);
console.log(generateReport.streaming);
console.log(generateReport.streamed);
console.log(generateReport.chunks);The streaming lifecycle is:
mutate()
│
▼
loading = true
streaming = true
chunks = []
streamed = false
│
├── chunk ──► chunks
│ streamed = true
│
├── chunk ──► chunks
│
└── stream end
│
▼
streaming = false
│
▼
success / errorStreaming applies to worker-module execution. Local execute functions receive optional stream / endStream helpers when stream: true is set on the definition; the worker streaming transport is specific to module-backed mutations.
See Query Pool and Workers for worker execution details.
Transferable Data
Worker-backed mutations use structured cloning by default.
For large binary inputs, Transferable Object transport can be enabled via defaults.transfer on the definition, or for an individual execution:
await processVideo.mutate(videoBuffer, {
transfer: true,
});Transferable transport moves supported objects across the worker boundary rather than cloning them.
Typical transferable values include:
ArrayBufferMessagePortImageBitmapOffscreenCanvas- typed-array backing buffers
DataViewbacking buffers
Because transferring detaches the object from the sender, transferable mutation input is not treated as reusable cached input.
See Transferable Data.
Optimistic Update + Server Result
An optimistic update does not have to be identical to the eventual server result.
For example:
const createPost = pool.mutation("createPost", {
execute: createPostRequest,
onMutate(input, ctx) {
const previous = ctx.getQueryData("posts") ?? [];
ctx.setQueryData("posts", [
...previous,
{
id: "temporary",
...input,
},
]);
return { previous };
},
onError(error, input, ctx) {
ctx.setQueryData("posts", ctx.previous);
},
invalidates: ["posts"],
});The optimistic value exists to provide immediate UI feedback.
After success, invalidation allows the query to obtain the authoritative server representation:
mutation
│
▼
optimistic update
│
▼
execute
│
success
│
▼
invalidate posts
│
▼
refresh posts
│
▼
authoritative resultThis pattern avoids treating optimistic state as authoritative server state.
Error Handling
A failed mutation transitions to "error" and exposes the thrown value through error:
try {
await createUser.mutate(input);
} catch (error) {
console.error(error);
}
console.log(createUser.status); // "error"
console.log(createUser.error);The mutation's previous successful data is not automatically discarded.
This allows the UI to continue displaying the last successful mutation result while showing the new failure:
const message = computed(() => {
if (createUser.loading) {
return "Saving…";
}
if (createUser.error) {
return "Save failed";
}
return createUser.data
? "Saved"
: "Ready";
});An onError hook can additionally perform rollback or other error handling.
Success Handling
onSuccess receives the mutation result, the input, and mutation context:
const updateUser = pool.mutation("updateUser", {
execute: updateUserRequest,
onSuccess(result, input, ctx) {
console.log("Server result:", result);
console.log("Input:", input);
},
});If the mutation has configured invalidations, successful invalidation processing follows the mutation's success path.
This makes it possible to distinguish:
mutation succeeds
│
├── onSuccess()
│
└── invalidation
│
▼
affected queriesThe mutation's own status describes the mutation; invalidated query statuses describe their own refresh executions.
A Complete CRUD Example
The following example shows a typical create operation with optimistic state, rollback, and invalidation:
const createUser = pool.mutation("createUser", {
execute: async (input, { signal }) => {
const res = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
onMutate(input, ctx) {
const previous = ctx.getQueryData("users") ?? [];
ctx.setQueryData("users", [
...previous,
{
id: `temporary-${Date.now()}`,
...input,
},
]);
return { previous };
},
onError(error, input, ctx) {
ctx.setQueryData("users", ctx.previous);
},
onSuccess(user) {
console.log("Created user:", user);
},
invalidates: ["users"],
});
await createUser.mutate({
name: "Ada",
email: "[email protected]",
});The complete operation is:
mutate(input)
│
▼
onMutate()
│
├── snapshot users
├── optimistic users update
│
▼
execute(input)
│
┌───────┴───────┐
│ │
success error
│ │
▼ ▼
onSuccess() onError()
│ │
▼ ▼
invalidate rollback
│
▼
refresh usersMutation vs Query
Queries and mutations both expose reactive execution state, but they have different roles.
| Concern | Query | Mutation |
|---|---|---|
| Primary purpose | Read | Write |
| Starts automatically | Yes, on registration | No |
| Manual execution | fetch() | mutate() |
| Dependency graph | Yes | No query dependency graph of its own |
| Cache | Query cache | No mutation result cache |
| Invalidation | Can be invalidated | Can invalidate queries |
| Optimistic updates | — | onMutate + setQueryData() |
| Rollback | — | onError |
| Cancellation | Yes | Yes |
| Worker module | Yes | Yes |
| Streaming | Worker modules | Worker modules (and local helpers when stream: true) |
The most important distinction is:
A query represents reusable asynchronous read state; a mutation represents an explicit asynchronous write operation.
Mutation Lifecycle Summary
A mutation starts in idle and only begins execution when mutate() is called:
mutate(input)
│
▼
┌─────────┐
│ loading │
└────┬────┘
│
┌────┴────┐
│ │
success error
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ success │ │ error │
└─────────┘ └─────────┘
│ │
└────┬────┘
│
mutate() again
│
▼
┌─────────┐
│ loading │
└─────────┘
cancel() while loading
│
▼
┌───────────┐
│ cancelled │
└───────────┘
reset() from any state
│
▼
┌──────┐
│ idle │
└──────┘Optimistic updates and invalidation sit around this lifecycle rather than replacing it:
mutate()
│
▼
onMutate()
│
▼
loading
/ \
/ \
success error
│ │
▼ ▼
onSuccess() onError()
│ │
▼ ▼
invalidation rollbackReactive UI Example
Because mutation fields are reactive, a component does not need separate state for the request lifecycle:
const saveUser = pool.mutation("saveUser", {
execute: saveUserRequest,
});
const buttonLabel = computed(() => {
if (saveUser.loading) {
return "Saving...";
}
if (saveUser.error) {
return "Retry";
}
if (saveUser.status === "success") {
return "Saved";
}
return "Save";
});The UI can then call:
await saveUser.mutate(formData);without manually maintaining:
let loading = false;
let error = null;
let saved = false;The mutation handle is the reactive representation of the asynchronous write lifecycle.
Cleanup
Mutation handles remain available after execution.
To clear their state:
createUser.reset();To stop an active execution:
createUser.cancel();At the pool level:
pool.terminate();terminates worker infrastructure and cancels in-flight mutations.
See Query Pool Overview for the complete pool lifecycle.
The Query Pool API Reference is the authoritative source for exact mutation signatures, hook arguments, execution options, and return values.