Skip to content

React QueryuseMutation

useMutation

Create, update, and delete data with type-safe mutations and automatic variable inference.

3 min readproxy mode

Signature

Access useMutation through the query caller's dot-notation API. Variables are passed to mutate(), not to the hook:

ts
const result = q.users.useMutation("create", options)

// q.<group>.useMutation(method, options?)
// group   — endpoint group name (e.g. "users")
// method  — method name within the group (e.g. "create")
// options — { requestOptions?, ...reactQueryMutationOptions }

// Returns: UseMutationResult with typed mutate function
// result.mutate(variables)   — fire and forget
// result.mutateAsync(variables) — returns Promise<R>

Variables go to mutate(), not the hook

Unlike useQuery where variables are passed in the options object, useMutation receives variables through the mutate(variables) call. This follows the standard React Query mutation pattern.

Create mutation

The most common pattern — submit a form and create a resource. The mutate function is typed based on the method's variable type:

components/create-user-form.tsx
"use client"

import { q } from "@/lib/query"

export function CreateUserForm() {
  // Method<User, { name: string; email: string; role: string }>
  const { mutate, isPending } = q.users.useMutation("create", {
    onSuccess: (user) => {
      console.log("Created:", user.name) // ← user is typed as User
    },
    onError: (error) => {
      console.error("Failed:", error.message)
    },
  })

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const form = new FormData(e.currentTarget)

    mutate({
      name: form.get("name") as string,
      email: form.get("email") as string,
      role: "user",
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Creating..." : "Create User"}
      </button>
    </form>
  )
}

Update mutation

For updates, pass all required fields (including the ID for path parameters) to mutate(). Courier extracts path parameters automatically:

components/edit-user-form.tsx
"use client"

import { q } from "@/lib/query"

export function EditUserForm({ user }: { user: User }) {
  // Method<User, { id: number; name: string; email: string }>
  const { mutate, isPending } = q.users.useMutation("update")

  const handleSave = (name: string, email: string) => {
    // Variables passed to mutate(), not to the hook options
    mutate({
      id: user.id,
      name,
      email,
    })
    // ^? TypeScript enforces { id: number; name: string; email: string }
  }

  return (
    <button onClick={() => handleSave("Jane", "jane@test.com")} disabled={isPending}>
      Save
    </button>
  )
}

Delete mutation

Delete methods typically return null and only need an ID. The variable type is inferred from the method definition:

components/delete-user-button.tsx
"use client"

import { q } from "@/lib/query"

export function DeleteUserButton({ userId }: { userId: number }) {
  // Method<null, { id: number }> → mutate takes { id: number }
  const { mutate, isPending } = q.users.useMutation("remove", {
    onSuccess: () => {
      console.log("User deleted")
    },
  })

  return (
    <button onClick={() => mutate({ id: userId })} disabled={isPending}>
      {isPending ? "Deleting..." : "Delete"}
    </button>
  )
}

Cache invalidation

After a mutation succeeds, invalidate related queries to trigger a refetch. Use the hierarchical query key format [source, group, method?, variables?]:

components/create-user-form.tsx
"use client"

import { useQueryClient } from "@noverax/courier-react"
import { q } from "@/lib/query"

export function CreateUserForm() {
  const queryClient = useQueryClient()

  const { mutate } = q.users.useMutation("create", {
    onSuccess: () => {
      // Invalidate a specific method
      queryClient.invalidateQueries({ queryKey: ["api", "users", "getAll"] })

      // Or invalidate the entire users group
      // queryClient.invalidateQueries({ queryKey: ["api", "users"] })
    },
  })

  return (
    <button onClick={() => mutate({ name: "Jane", email: "jane@test.com", role: "user" })}>
      Create
    </button>
  )
}

Group-level invalidation

Use ["api", "users"] to invalidate all queries in the users group at once — getAll, getById, and any other method. Use ["api"] to invalidate every query for that source.

Optimistic updates

For instant UI feedback, update the cache optimistically before the server responds. Roll back on error, and always refetch on settle to ensure consistency:

components/user-manager.tsx
"use client"

import { useQueryClient } from "@noverax/courier-react"
import { q } from "@/lib/query"

type User = { id: number; name: string; email: string }

export function UserManager() {
  const queryClient = useQueryClient()
  const { data: users } = q.users.useQuery("getAll")

  const { mutate: updateUser } = q.users.useMutation("update", {
    // 1. Cancel outgoing refetches so they don't overwrite our optimistic update
    onMutate: async (variables) => {
      await queryClient.cancelQueries({ queryKey: ["api", "users", "getAll"] })

      // 2. Snapshot the previous value
      const previous = queryClient.getQueryData<User[]>(["api", "users", "getAll"])

      // 3. Optimistically update the cache
      queryClient.setQueryData<User[]>(["api", "users", "getAll"], (old) =>
        old?.map((u) => (u.id === variables.id ? { ...u, ...variables } : u))
      )

      return { previous }
    },

    // 4. On error, roll back to the previous value
    onError: (_error, _variables, context) => {
      if (context?.previous) {
        queryClient.setQueryData(["api", "users", "getAll"], context.previous)
      }
    },

    // 5. Always refetch after error or success to ensure consistency
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ["api", "users", "getAll"] })
    },
  })

  return (
    <ul>
      {users?.map((user) => (
        <li key={user.id}>
          {user.name}
          <button onClick={() => updateUser({ id: user.id, name: "Updated", email: user.email })}>
            Update
          </button>
        </li>
      ))}
    </ul>
  )
}

What onMutate returns reaches onError and onSettled typed as it was returned, so context.previous above is a User[] | undefined. TypeScript reads the object from top to bottom: write onMutate before the callbacks that use its result.

Next steps