Skip to content

Mutations

The useMutation hook wraps a GraphQL mutation and returns a tuple of [mutate, pending]. Call mutate with your variables to execute it.

import { graphql, useMutation } from '$houdini'
export function AddCommentForm({ postId }: { postId: string }) {
const [mutate, pending] = useMutation(graphql(`
mutation AddComment($postId: ID!, $body: String!) {
addComment(postId: $postId, body: $body) {
id
body
}
}
`))
async function handleSubmit(e) {
e.preventDefault()
await mutate({
variables: { postId, body: e.target.body.value }
})
}
return (
<form onSubmit={handleSubmit}>
<textarea name="body" />
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Add comment'}
</button>
</form>
)
}
import { graphql, useMutation } from '$houdini'
export function AddCommentForm({ postId }) {
const [mutate, pending] = useMutation(graphql(`
mutation AddComment($postId: ID!, $body: String!) {
addComment(postId: $postId, body: $body) {
id
body
}
}
`))
async function handleSubmit(e) {
e.preventDefault()
await mutate({
variables: { postId, body: e.target.body.value }
})
}
return (
<form onSubmit={handleSubmit}>
<textarea name="body"/>
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Add comment'}
</button>
</form>
)
}

pending is true from the moment mutate is called until the server responds.

Error Handling

Mutations always throw on error; unlike queries, there’s no opt-in. Wrap calls in try/catch and inspect the RuntimeGraphQLError:

import { graphql, useMutation, RuntimeGraphQLError } from '$houdini'
const [mutate, pending] = useMutation(graphql(`...`))
try {
await mutate({ variables: { ... } })
} catch (e) {
if (e instanceof RuntimeGraphQLError) {
// e.message is the joined error messages
// e.raw is the full errors array from the GraphQL response
console.error(e.raw)
}
}

Cache Updates

After a successful mutation, Houdini automatically updates any cached fields that appear in the mutation’s response. If the mutation returns a type that’s already in the cache, fields are merged and any components watching those fields re-render.

For mutations that add or remove items from lists, see Updating Lists.

Refetching Changed Data

Most of the time, you’re best off including the changed fields in the mutation itself, either by requesting them directly or by spreading the fragments your components already define. That keeps everything to a single round trip and gives the fastest experience. Sometimes that isn’t possible, though, or it gets unwieldy. In those cases you can use the @refetch directive, which tells Houdini to find the queries that depend on the returned entity and refetch them.

mutation FavoriteBook($id: ID!) {
favoriteBook(id: $id) {
book @refetch {
id
}
}
}

A few things to keep in mind:

  • @refetch goes on the field that returns the entity, not on its id. That’s how Houdini knows which record changed.
  • It also works on a field that returns a list of entities, in which case every record in the list is refreshed.
  • It can’t be combined with @list or @paginate, which already keep their data in sync.

Optimistic Updates

Pass an optimisticResponse to apply an immediate cache update before the server responds. If the mutation fails, the optimistic update is rolled back:

await mutate({
variables: { postId, body: 'Great post!' },
optimisticResponse: {
addComment: {
id: 'temp-id',
body: 'Great post!'
}
}
})

See Optimistic Updates for a full walkthrough.

Forms

To back a <form> with a mutation — including one that works before JavaScript loads — reach for useMutationForm instead of wiring up onSubmit by hand. See Forms.

Writing the Session

A mutation can set the user’s session by marking it @session. The field named by the directive’s path becomes the session, which the server writes into an httpOnly cookie:

mutation Login($email: String!, $password: String!) @session(path: "login.session") {
login(email: $email, password: $password) {
session { token }
}
}

Login is the obvious case, but anything that ends in “set the session from a mutation result” works the same way, including merging a preference into the existing session or clearing it on logout. The value always comes from the resolver, never from client input. The full story (replace vs. merge vs. clear, and running it without JavaScript via @endpoint) lives in the authentication guide.

Deduplication

By default, nothing stops the same operation from running multiple times simultaneously. The @dedupe directive gives us control over that behavior:

query UserProfile($id: ID!) @dedupe {
user(id: $id) {
name
}
}

With no arguments, @dedupe prevents a second request from firing if an identical one is already in-flight. To cancel the first request instead of dropping the second, pass cancelFirst: true:

query UserProfile($id: ID!) @dedupe(cancelFirst: true) {
user(id: $id) {
name
}
}

The match argument controls what counts as "identical":

  • Operation: dedupe if any execution of this operation is pending, regardless of variables (default)
  • Variables: dedupe only if the variables also match
  • None: never dedupe