Mutations Reference
Table of Contents
- useMutation Hook
- Mutation Variables
- Loading and Error States
- Optimistic UI
- Cache Updates
- Refetch Queries
- Error Handling
useMutation Hook
The useMutation hook is used to execute GraphQL mutations.
Basic Usage
import { gql } from "@apollo/client";
import { useMutation } from "@apollo/client/react";
const ADD_TODO = gql`
mutation AddTodo($text: String!) {
addTodo(text: $text) {
id
text
completed
}
}
`;
function AddTodo() {
const [addTodo, { data, loading, error }] = useMutation(ADD_TODO);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget;
const text = new FormData(form).get("text") as string;
addTodo({ variables: { text } });
form.reset();
}}
>
<input name="text" placeholder="Add todo" />
<button type="submit" disabled={loading}>
Add
</button>
{error && <p>Error: {error.message}</p>}
</form>
);
}Return Tuple
const [
mutateFunction, // Function to call to execute mutation
{
data, // Mutation result data
loading, // True while mutation is in flight
error, // ApolloError if mutation failed
called, // True if mutation has been called
reset, // Reset mutation state
client, // Apollo Client instance
},
] = useMutation(MUTATION);Mutation Variables
Variables in Options
const [createUser] = useMutation(CREATE_USER, {
variables: {
input: {
name: "Default User",
email: "default@example.com",
},
},
});
// Call with default variables
await createUser();
// Override variables
await createUser({
variables: {
input: {
name: "Custom User",
email: "custom@example.com",
},
},
});TypeScript Types
Use TypedDocumentNode instead of generic type parameters:
import { gql, TypedDocumentNode } from "@apollo/client";
interface CreateUserData {
createUser: {
id: string;
name: string;
email: string;
};
}
interface CreateUserVariables {
input: {
name: string;
email: string;
};
}
const CREATE_USER: TypedDocumentNode<CreateUserData, CreateUserVariables> = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
email
}
}
`;
const [createUser, { data, loading }] = useMutation(CREATE_USER);
const { data } = await createUser({
variables: {
input: { name: "John", email: "john@example.com" },
},
});
// data.createUser is fully typedLoading and Error States
Handling in UI
function CreatePost() {
const [createPost, { loading, error, data, reset }] =
useMutation(CREATE_POST);
if (data) {
return (
<div>
<p>Post created: {data.createPost.title}</p>
<button onClick={reset}>Create another</button>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<input name="title" disabled={loading} />
<textarea name="content" disabled={loading} />
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Post"}
</button>
{error && (
<div className="error">
<p>Failed to create post: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)}
</form>
);
}Async/Await Pattern
If you only need the promise without using the hook's loading/data state, use client.mutate instead:
import { useApolloClient } from "@apollo/client/react";
function CreatePost() {
const client = useApolloClient();
async function handleSubmit(formData: FormData) {
try {
const { data } = await client.mutate({
mutation: CREATE_POST,
variables: {
input: {
title: formData.get("title"),
content: formData.get("content"),
},
},
});
console.log("Created:", data.createPost);
router.push(`/posts/${data.createPost.id}`);
} catch (error) {
console.error("Failed to create post:", error);
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(new FormData(e.currentTarget));
}}
>
...
</form>
);
}If you do use the hook's state, e.g. because you want to render the loading state, errors or returned data, you can also use the useMutation hook with async..await in your handler:
function CreatePost() {
const [createPost, { loading }] = useMutation(CREATE_POST);
async function handleSubmit(formData: FormData) {
try {
const { data } = await createPost({
variables: {
input: {
title: formData.get("title"),
content: formData.get("content"),
},
},
});
console.log("Created:", data.createPost);
router.push(`/posts/${data.createPost.id}`);
} catch (error) {
console.error("Failed to create post:", error);
}
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(new FormData(e.currentTarget));
}}
>
<button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Post"}
</button>
</form>
);
}Optimistic UI
Optimistic UI immediately reflects the expected result of a mutation before the server responds.
Basic Optimistic Response
Important: optimisticResponse needs to be a full valid response for the mutation. A partial result might result in subtle errors.
const [addTodo] = useMutation(ADD_TODO, {
optimisticResponse: {
addTodo: {
__typename: "Todo",
id: "temp-id",
text: "New todo",
completed: false,
},
},
});Dynamic Optimistic Response
function TodoList() {
const [addTodo] = useMutation(ADD_TODO);
const handleAdd = (text: string) => {
addTodo({
variables: { text },
optimisticResponse: {
addTodo: {
__typename: "Todo",
id: `temp-${Date.now()}`,
text,
completed: false,
},
},
});
};
return <AddTodoForm onAdd={handleAdd} />;
}Optimistic Response with Cache Update
const [toggleTodo] = useMutation(TOGGLE_TODO, {
optimisticResponse: ({ id }) => ({
toggleTodo: {
__typename: "Todo",
id,
completed: true, // Assume success
},
}),
update: (cache, { data }) => {
// This runs twice: once with optimistic data, once with server data
cache.modify({
id: cache.identify(data.toggleTodo),
fields: {
completed: () => data.toggleTodo.completed,
},
});
},
});Cache Updates
Using update Function
const [addTodo] = useMutation(ADD_TODO, {
update: (cache, { data }) => {
// Read existing todos from cache
const existingTodos = cache.readQuery<{ todos: Todo[] }>({
query: GET_TODOS,
});
// Write updated list back to cache
cache.writeQuery({
query: GET_TODOS,
data: {
todos: [...(existingTodos?.todos ?? []), data.addTodo],
},
});
},
});cache.modify
const [deleteTodo] = useMutation(DELETE_TODO, {
update: (cache, { data }) => {
cache.modify({
fields: {
todos: (existingTodos: Reference[], { readField }) => {
return existingTodos.filter(
(todoRef) => readField("id", todoRef) !== data.deleteTodo.id
);
},
},
});
},
});cache.evict
const [deleteUser] = useMutation(DELETE_USER, {
update: (cache, { data }) => {
// Remove the user object from cache entirely
cache.evict({ id: cache.identify(data.deleteUser) });
// Clean up dangling references
cache.gc();
},
});Updating Related Queries
const [createPost] = useMutation(CREATE_POST, {
update: (cache, { data }) => {
// Update author's post count
cache.modify({
id: cache.identify({ __typename: "User", id: data.createPost.authorId }),
fields: {
postCount: (existing) => existing + 1,
posts: (existing, { toReference }) => [
...existing,
toReference(data.createPost),
],
},
});
// Add to feed
cache.modify({
fields: {
feed: (existing, { toReference }) => [
toReference(data.createPost),
...existing,
],
},
});
},
});Refetch Queries
Basic Refetch
There are three refetch notations:
- String:
refetchQueries: ['getTodos']- refetches all activegetTodosqueries - Query document:
refetchQueries: [GET_TODOS]- refetches all active queries using this document - Object:
refetchQueries: [{ query: GET_TODOS }, { query: GET_TODOS, variables: { page: 25 } }]- fetches the query, regardless if it's actively used in the UI
const [addTodo] = useMutation(ADD_TODO, {
// Refetch all active GET_TODOS queries
refetchQueries: ["getTodos"],
// Or: refetchQueries: [GET_TODOS],
});
// Fetch specific query with variables (even if not active)
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: [{ query: GET_TODOS }, { query: GET_TODO_COUNT }],
});Conditional Refetch
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: (result) => {
if (result.data?.addTodo.priority === "HIGH") {
return [{ query: GET_HIGH_PRIORITY_TODOS }];
}
return [{ query: GET_TODOS }];
},
});Refetch Active Queries
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: "active", // Refetch all active queries
// Or: 'all' to refetch all queries (including inactive)
});awaitRefetchQueries
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: [{ query: GET_TODOS }],
awaitRefetchQueries: true, // Wait for refetch before resolving mutation
});onQueryUpdated
Returning true from onQueryUpdated causes a refetch. Don't call refetch() manually inside onQueryUpdated, as it won't retain the query and might cancel it early.
const [addTodo] = useMutation(ADD_TODO, {
update: (cache, { data }) => {
// Update cache...
},
onQueryUpdated: (observableQuery) => {
// Called for each query affected by cache update
console.log(`Query ${observableQuery.queryName} was updated`);
// Return true to refetch
return true;
},
});Error Handling
Error Policy
const [createUser, { loading }] = useMutation(CREATE_USER, {
errorPolicy: "all", // Return both data and errors
});
const { data, errors } = await createUser({
variables: { input },
});
// Handle partial success
if (data?.createUser) {
console.log("User created:", data.createUser);
}
if (errors) {
console.warn("Some errors occurred:", errors);
}onError Callback
const [createUser] = useMutation(CREATE_USER, {
onError: (error) => {
// Handle error globally
toast.error(`Failed to create user: ${error.message}`);
// Log to error tracking service
Sentry.captureException(error);
},
onCompleted: (data) => {
toast.success(`User ${data.createUser.name} created!`);
},
});Field-Level Errors
const [createUser] = useMutation(CREATE_USER, {
errorPolicy: "all",
});
const handleSubmit = async (input: CreateUserInput) => {
const { data, errors } = await createUser({
variables: { input },
});
// Handle GraphQL validation errors
const fieldErrors = errors?.reduce(
(acc, error) => {
const field = error.extensions?.field as string;
if (field) {
acc[field] = error.message;
}
return acc;
},
{} as Record<string, string>
);
if (fieldErrors?.email) {
setEmailError(fieldErrors.email);
}
};