| name | mutations |
| description | Mutation render-prop component and mutationOptions for TanStack Query v4. Load when adding mutations inside list rows, loops, or conditionals where useMutation cannot be called, removing per-row wrapper components, or reusing mutation configs with mutationOptions. |
| metadata | {"type":"sub-skill","library":"@suspensive/react-query-4","library_version":"3.21.2","framework":"react"} |
| requires | ["react-query"] |
| sources | ["toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/Mutation.mdx","toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/mutationOptions.mdx","toss/suspensive:docs/suspensive.org/src/content/en/docs/react-query/migration/migrate-to-v3.mdx"] |
This skill builds on react-query. Read ../SKILL.md first.
<Mutation/> exposes a useMutation result as a render prop so each list row or conditional branch can own its mutation without extracting a wrapper component. The render prop receives the full v4 UseMutationResult (mutate, mutateAsync, isLoading, isError, error, data, reset). Note the v4 naming: the in-flight flag is isLoading, not v5's isPending.
Setup
npm install @suspensive/react-query-4 @tanstack/react-query@4
Import Mutation from '@suspensive/react-query-4'; import mutationOptions from '@tanstack/react-query' (official since TSQ 4.44+, which the package's peer range requires; the Suspensive re-export is deprecated).
Core Patterns
One mutation per list row
'use client'
import { Mutation } from '@suspensive/react-query-4'
import { useSuspenseQuery } from '@tanstack/react-query'
import { api } from '~/api'
export const PostsPage = () => {
const { data: posts } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: () => api.getPosts(),
})
return posts.map((post) => (
<Mutation
key={post.id}
mutationFn={({ content }: { content: string }) => api.editPost({ postId: post.id, content })}
>
{(postMutation) => (
<div>
{postMutation.isLoading ? <Spinner /> : null}
<div>{post.content}</div>
<textarea
disabled={postMutation.isLoading}
onBlur={(e) => postMutation.mutate({ content: e.target.value })}
/>
</div>
)}
</Mutation>
))
}
Reusable configs with mutationOptions
'use client'
import { Mutation } from '@suspensive/react-query-4'
import { mutationOptions, useMutation } from '@tanstack/react-query'
const editPostMutationOptions = (postId: number) =>
mutationOptions({
mutationFn: (content: string) =>
fetch(`/api/posts/${postId}`, {
method: 'PATCH',
body: JSON.stringify({ content }),
}).then((res) => res.json()),
})
const useEditPost = (postId: number) => useMutation(editPostMutationOptions(postId))
export const EditPost = ({ postId }: { postId: number }) => (
<Mutation {...editPostMutationOptions(postId)}>
{({ mutate, isLoading }) => (
<div>
<p>{isLoading ? 'Updating...' : 'Latest updated'}</p>
<textarea onBlur={(e) => mutate(e.target.value)} disabled={isLoading} />
</div>
)}
</Mutation>
)
Invalidating queries on success
'use client'
import { Mutation } from '@suspensive/react-query-4'
import { useQueryClient } from '@tanstack/react-query'
import { api } from '~/api'
export const DeletePostButton = ({ postId }: { postId: number }) => {
const queryClient = useQueryClient()
return (
<Mutation
mutationFn={() => api.deletePost(postId)}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['posts'] })}
>
{({ mutate, isLoading }) => (
<button disabled={isLoading} onClick={() => mutate()}>
{isLoading ? 'Deleting...' : 'Delete'}
</button>
)}
</Mutation>
)
}
Common Mistakes
[MEDIUM] Wrapper component per row just for useMutation
Wrong:
{
posts.map((post) => <PostItemWithMutation key={post.id} post={post} />)
}
const PostItemWithMutation = ({ post }: { post: Post }) => {
const deleteMutation = useMutation({ mutationFn: () => api.deletePost(post.id) })
return (
<button disabled={deleteMutation.isLoading} onClick={() => deleteMutation.mutate()}>
delete
</button>
)
}
Correct:
{
posts.map((post) => (
<Mutation key={post.id} mutationFn={() => api.deletePost(post.id)}>
{({ mutate, isLoading }) => (
<button disabled={isLoading} onClick={() => mutate()}>
delete
</button>
)}
</Mutation>
))
}
Hooks cannot be called in loops, so agents invent awkwardly named wrapper components; <Mutation> works inline per row with no extra depth.
Source: docs/suspensive.org/src/content/en/docs/react-query/Mutation.mdx
[MEDIUM] Using v5 isPending on v4 mutation results
Wrong:
<Mutation mutationFn={() => api.deletePost(postId)}>
{({ mutate, isPending }) => (
<button disabled={isPending} onClick={() => mutate()}>
delete
</button>
)}
</Mutation>
Correct:
<Mutation mutationFn={() => api.deletePost(postId)}>
{({ mutate, isLoading }) => (
<button disabled={isLoading} onClick={() => mutate()}>
delete
</button>
)}
</Mutation>
The render prop receives TSQ v4's UseMutationResult, where the in-flight flag is isLoading; isPending only exists in TSQ v5 (and @suspensive/react-query-5).
Source: docs/suspensive.org/src/content/en/docs/react-query/Mutation.mdx
[MEDIUM] Importing mutationOptions from Suspensive
Wrong:
import { mutationOptions } from '@suspensive/react-query-4'
Correct:
import { mutationOptions } from '@tanstack/react-query'
mutationOptions is official in TanStack Query since v4.44 (the package's peer range requires ^4.44.0); the Suspensive re-export exists only for compatibility and is deprecated.
Source: docs/suspensive.org/src/content/en/docs/react-query/mutationOptions.mdx
See also: ../declarative-queries/SKILL.md for the matching SuspenseQuery pattern, ../SKILL.md for which imports come from @tanstack/react-query vs Suspensive.