| name | mongez-atomic-query-mutations |
| description | How to use useMutation for write-side operations, including optimistic updates with onMutate/onError rollback and direct cache writes with updateQueryData.
TRIGGER when: code imports `useMutation`, `updateQueryData`, `UseMutationOptions`, `UseMutationResult`, or `MutationStatus` from `@mongez/atomic-query`, or references `mutate`, `mutateAsync`, `onMutate`, `onSuccess`, `onError`, `onSettled`, `isPending`, or `reset` in a mutation context; user asks "how do I POST / PUT / PATCH / DELETE / do an optimistic update / roll back on error / write to the cache without refetching"; typical import `import { useMutation, queryAtom } from "@mongez/atomic-query"`.
SKIP: read-side `useQuery` calls — use `mongez-atomic-query-basic-query` or `mongez-atomic-query-queries`; forcing a refetch after a mutation completes — use `mongez-atomic-query-invalidation`; array-shaped helpers `push`/`remove`/`sort` for list updates — use `mongez-atomic-query-list-helpers`; cache lifecycle/GC questions — use `mongez-atomic-query-cache`.
|
Mutations and optimistic updates
When to use
Use this skill when:
- Someone needs to perform a write operation (POST, PUT, PATCH, DELETE).
- Someone wants to update the UI before the server responds (optimistic update).
- Someone needs to roll back an optimistic change when the server returns an error.
- Someone wants to write to the cache directly without firing a network request (
updateQueryData).
- Someone asks about
isPending, isError, isSuccess, reset(), or mutateAsync.
- Someone asks about aborting a mutation or preventing double-submit.
How to use
Basic mutation
"use client";
import { useMutation, queryAtom } from "@mongez/atomic-query";
function CreateUserForm() {
const createUser = useMutation<User, { name: string }>({
mutationFn: async ({ name }, { signal }) =>
fetch("/api/users", {
method: "POST",
body: JSON.stringify({ name }),
signal,
}).then(r => r.json()),
onSuccess: (created) => {
queryAtom.updateQueryData<User[]>(["users"], old =>
[...(old ?? []), created]
);
},
onSettled: () => {
queryAtom.invalidate({ queryKey: ["users", "stats"] });
},
});
return (
<button
disabled={createUser.isPending}
onClick={() => createUser.mutate({ name: "Alice" })}
>
{createUser.isPending ? "Creating…" : "Create user"}
</button>
);
}
Full options
useMutation<TData, TVariables, TContext>({
mutationFn: (variables: TVariables, ctx: { signal: AbortSignal }) =>
Promise<TData>;
onMutate?: (variables: TVariables) => TContext | Promise<TContext>;
onSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) =>
void | Promise<void>;
onError?: (error: unknown, variables: TVariables, context: TContext | undefined) =>
void | Promise<void>;
onSettled?: (
data: TData | undefined,
error: unknown,
variables: TVariables,
context: TContext | undefined,
) => void | Promise<void>;
})
Return shape
| Field | Type | Meaning |
|---|
mutate | (variables) => Promise<TData> | Fire the mutation; returns a promise. |
mutateAsync | (variables) => Promise<TData> | Alias for mutate. |
reset | () => void | Clear state and abort any in-flight call. |
status | "idle" | "pending" | "error" | "success" | Current lifecycle state. |
isPending | boolean | Mutation is in flight. |
isError | boolean | Last call failed. |
isSuccess | boolean | Last call succeeded. |
isIdle | boolean | Never fired, or after reset(). |
data | TData | undefined | Result of the last successful call. |
error | unknown | Error from the last failed call. |
variables | TVariables | undefined | Variables passed to the last call. |
Optimistic update with rollback
const updateUser = useMutation<User, { id: number; name: string }, { previous: User[] | undefined }>({
onMutate: async ({ id, name }) => {
const previous = queryAtom.getData(["users"]) as User[] | undefined;
queryAtom.updateQueryData<User[]>(["users"], old =>
(old ?? []).map(u => u.id === id ? { ...u, name } : u)
);
return { previous };
},
mutationFn: async ({ id, name }, { signal }) =>
fetch(`/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify({ name }),
signal,
}).then(r => r.json()),
onError: (_err, _vars, context) => {
if (context?.previous !== undefined) {
queryAtom.updateQueryData(["users"], () => context.previous!);
}
},
onSuccess: (serverUser) => {
queryAtom.updateQueryData<User[]>(["users"], old =>
(old ?? []).map(u => u.id === serverUser.id ? serverUser : u)
);
},
});
Direct cache write without a mutation hook
When you only need to update the cache without a side effect:
queryAtom.updateQueryData<User[]>(["users"], old => [...(old ?? []), newUser]);
queryAtom.updateQueryData<User[]>(["users"], old =>
(old ?? []).filter(u => u.id !== deletedId)
);
queryAtom.updateQueryData<User[]>(["users"], old =>
(old ?? []).map(u => u.id === updated.id ? updated : u)
);
Standalone export:
import { updateQueryData } from "@mongez/atomic-query";
Key details / Pitfalls
-
Mutations do NOT write to the queryAtom cache themselves. The hook tracks its own local status/data/error. Cache interaction is always explicit: you call queryAtom.updateQueryData, queryAtom.push, queryAtom.invalidate, etc. inside the callbacks.
-
A second mutate call automatically aborts the previous in-flight one. There is no built-in debounce; if you need it, add your own. The reset() method also aborts the current in-flight call.
-
Unmounting aborts the in-flight mutation. The onSuccess/onError/onSettled callbacks are NOT called when the signal is aborted (the hook checks controller.signal.aborted before invoking them).
-
onMutate runs before mutationFn, even before the network request starts. If onMutate throws, mutationFn is never called and onError fires with the onMutate error.
-
updateQueryData is a no-op when the query does not exist yet. If you call it for a key that has never been loaded, it silently does nothing. Pre-populate with seedQuery if you need it to work before the first useQuery mount.
-
mutate vs mutateAsync: They are the same function. Both return a promise and throw on failure. The distinction from TanStack Query (where mutate swallowed errors) does not apply here — always wrap in try/catch or .catch() when using the return value.