useM
Type-safe mutations with optimistic updates, multi-target snapshots, and automatic tag invalidation.
useM is the mutation hook. It wraps useMutation with route-aware typing, automatic tag invalidation, and a structured optimistic-update API — Mastering Mutations as defaults.
const createPost = useM("createPost");
createPost.mutate({
params: { facilityId },
body: { title: "Hello", body: "World" },
});Signature
function useM<RouteId extends keyof TSchema & string>(
routeId: RouteId,
options?: UseMOptions<TSchema, RouteId>,
): UseMutationResult<
RouteResponse<TSchema[RouteId]>,
Error,
{ params?; body?; searchParams? },
{ snapshots: Array<{ key: readonly unknown[]; previous: unknown }> }
>;All request data — path params, body, and searchParams — is passed as the variables object when you call mutate / mutateAsync. Nothing request-specific is fixed at hook creation time.
Basic mutation
function NewPost({ facilityId }: { facilityId: string }) {
const createPost = useM("createPost");
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
createPost.mutate({
params: { facilityId },
body: {
title: String(data.get("title")),
body: String(data.get("body")),
},
});
}}
>
<input name="title" required />
<textarea name="body" required />
<button disabled={createPost.isPending}>Publish</button>
{createPost.isError && <p>{createPost.error.message}</p>}
</form>
);
}Because the route's invalidatesTags match tags registered by read routes, any useQ("listPosts", …) currently tagged "posts" refetches automatically after the mutation settles.
Optimistic updates
useM accepts an optimisticUpdates array. Each entry says: "before the request goes out, update this cached query as if the mutation already succeeded." If the request fails, every snapshot rolls back.
Each entry has a target (which queries to touch) and an updater:
optimisticUpdates?: ReadonlyArray<{
target:
| { routeId: keyof TSchema & string; input?: RouteInput }
| { tags: ReadonlyArray<Tag> };
updater: (previous: unknown, variables: { params?; body? }) => unknown;
}>;A routeId target resolves to that route's exact query key (built from input). A tags target resolves via the TagRegistry to every currently-registered query key carrying any of those tags.
Single target
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: (prev, { body }) => prev && { ...(prev as Post), ...body },
},
],
});The updater receives the previous cached value and the { params, body } from the variables passed to mutate. Return the next value (or prev to do nothing).
Multiple targets
A mutation often touches several caches. Each target is applied independently with its own snapshot:
const updatePost = useM("updatePost", {
optimisticUpdates: [
{
// Detail page
target: { routeId: "getPost", input: { params: { facilityId, postId } } },
updater: (prev, { body }) => prev && { ...(prev as Post), ...body },
},
{
// List view
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, { body }) =>
(prev as Post[] | undefined)?.map((p) =>
p.id === postId ? { ...p, ...body } : p,
),
},
],
});List insert / delete
const createPost = useM("createPost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, { body }) => [
{
id: `temp-${crypto.randomUUID()}`,
facilityId,
createdAt: new Date().toISOString(),
...(body as CreatePostInput),
},
...((prev as Post[] | undefined) ?? []),
],
},
],
});
const deletePost = useM("deletePost", {
optimisticUpdates: [
{
target: { routeId: "listPosts", input: { params: { facilityId } } },
updater: (prev, { params }) =>
(prev as Post[] | undefined)?.filter((p) => p.id !== params?.postId),
},
],
});Rollback semantics
Under the hood, useM does this for every resolved target key:
cancelQueries(key)to stop any in-flight refetch from clobbering the optimistic value.getQueryData(key)to snapshot the current state.setQueryData(key, updater(prev, { params, body }))to apply the optimistic update.- On error, restore every snapshot.
- On settle, run tag invalidation so any drift is reconciled.
The snapshots are exposed as the mutation context:
createPost.mutate(vars, {
onError: (err, vars, ctx) => {
// ctx is { snapshots }; useM already used it for rollback.
console.error("rolled back", ctx?.snapshots.length, "snapshots");
},
});additionalInvalidatesTags
Sometimes a mutation needs to invalidate caches that aren't in the schema's invalidatesTags (e.g. cross-cutting "feed" views). Add them at the call site:
const createPost = useM("createPost", {
additionalInvalidatesTags: ["feed", { type: "post" }],
});additionalInvalidatesTags are unioned with the schema's invalidatesTags (static array or ({ response, variables }) => Tag[] function). Both run when the mutation settles.
Hook-level and per-call options
UseMOptions extends TanStack's useMutation options minus mutationFn — so onMutate, onSuccess, onError, onSettled, retry, retryDelay, and mutationKey all work. The hook wraps onMutate, onError, and onSettled internally for the optimistic/invalidations machinery, then calls yours after its own work.
mutate also accepts the standard per-call options:
createPost.mutate(
{ params: { facilityId }, body: { title, body } },
{
onSuccess: (post) => router.push(`/posts/${post.id}`),
onError: (err) => toast.error(err.message),
onSettled: () => analytics.track("post_create_attempt"),
},
);These run in addition to the ones declared at the hook level — useful for one-off behaviors.
Tag chaining recap
| Where you declare it | What it does |
|---|---|
Route tags (in schema, on a read route) | Labels the cache entry so mutations can find it. |
Route invalidatesTags (in schema, on a write route) | Invalidates queries registered with matching tags when the mutation settles. |
useM additionalInvalidatesTags (call-site) | Extra tags to invalidate for this particular hook. |
Tags are string | { type: string; id?: string | number } and match by exact identity after normalization — { type: "post", id: "p1" } matches only queries registered with { type: "post", id: "p1" } (or the equivalent string "post:p1"), and "posts" matches only "posts". To support both broad and narrow invalidation, register both from the read route:
tags: ({ response }) => ["posts", { type: "post", id: response.id }],See Tag invalidation for the deep dive.
Need to invalidate or update queries imperatively outside a mutation? Use useQClient.