Skip to content

useM

useM is the mutation hook. It wraps useMutation with route-aware typing, automatic tag invalidation, and a structured optimistic-update API.

const createPost = useM("createPost", { pathParams: { facilityId } });
createPost.mutate({ body: { title: "Hello", body: "World" } });
function useM<TRoute extends MutatingRouteKey<TSchema>>(
routeName: TRoute,
options: UseMOptions<TSchema, TRoute, TParsedError>,
): UseMutationResult<…>;

Path params are passed up-front in options.pathParams because they’re typically static per render. The body (and per-call search params, headers, etc.) are passed when you call mutate.

function NewPost({ facilityId }: { facilityId: string }) {
const createPost = useM("createPost", { pathParams: { facilityId } });
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
createPost.mutate({
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 chain into matching tags, any useQ("listPosts", …) for the same facilityId refetches automatically after the mutation settles.

useM accepts an optimistic 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.

const updatePost = useM("updatePost", {
pathParams: { facilityId, postId },
optimistic: [
{
route: "getPost",
params: { pathParams: { facilityId, postId } },
updater: (prev, vars) => prev && { ...prev, ...vars.body },
},
],
});

The updater receives the previous cached value and the variables passed to mutate. Return the next value (or prev to do nothing).

A mutation often touches several caches. Each OptimisticTarget is applied independently with its own snapshot:

const updatePost = useM("updatePost", {
pathParams: { facilityId, postId },
optimistic: [
{
// Detail page
route: "getPost",
params: { pathParams: { facilityId, postId } },
updater: (prev, vars) => prev && { ...prev, ...vars.body },
},
{
// List view
route: "listPosts",
params: { pathParams: { facilityId } },
updater: (prev, vars) =>
prev?.map((p) => (p.id === postId ? { ...p, ...vars.body } : p)),
},
],
});
const createPost = useM("createPost", {
pathParams: { facilityId },
optimistic: [
{
route: "listPosts",
params: { pathParams: { facilityId } },
updater: (prev, vars) => [
{
id: `temp-${crypto.randomUUID()}`,
facilityId,
createdAt: new Date().toISOString(),
...vars.body,
},
...(prev ?? []),
],
},
],
});
const deletePost = useM("deletePost", {
pathParams: { facilityId, postId },
optimistic: [
{
route: "listPosts",
params: { pathParams: { facilityId } },
updater: (prev) => prev?.filter((p) => p.id !== postId),
},
],
});

Under the hood, useM does this for every OptimisticTarget:

  1. cancelQueries(key) to stop any in-flight refetch from clobbering the optimistic value.
  2. getQueryData(key) to snapshot the current state.
  3. setQueryData(key, updater(prev, vars)) to apply the optimistic update.
  4. On onError, restore each snapshot in reverse order.
  5. On onSettled, run tag invalidation so any drift is reconciled.

You see this in useM.context:

createPost.mutate(vars, {
onError: (err, vars, ctx) => {
// ctx is the array of snapshots; useM already used it for rollback.
console.error("rolled back", ctx?.snapshots?.length, "snapshots");
},
});

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", {
pathParams: { facilityId },
additionalInvalidatesTags: [
{ type: "feed" },
{ type: "post", facilityId: () => "everywhere" },
],
});

additionalInvalidatesTags are unioned with the schema’s invalidatesTags. Both run in onSettled.

mutate accepts the standard TanStack Query options:

createPost.mutate(
{ 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.

Where you declare itWhat 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)Runs invalidateQueries for matching tags in onSettled.
useM additionalInvalidatesTags (call-site)Extra tags to invalidate for this particular hook.

The matching algorithm:

  • Dynamic tag { type: "post", facilityId: "f1" } matches read tags with same type and equal-or-missing facilityId.
  • Static tag { type: "post" } matches everything with type: "post", ignoring other keys.

See Tag invalidation for the deep dive.