@suspensive/react-query-4
Version:
Suspensive interfaces for @tanstack/react-query@4
216 lines (172 loc) • 6.26 kB
Markdown
---
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
```bash
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
```tsx
'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
```tsx
'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()),
})
// Same options work with the hook...
const useEditPost = (postId: number) => useMutation(editPostMutationOptions(postId))
// ...and spread directly into the component
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
```tsx
'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:
```tsx
{
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:
```tsx
{
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:
```tsx
<Mutation mutationFn={() => api.deletePost(postId)}>
{({ mutate, isPending }) => (
<button disabled={isPending} onClick={() => mutate()}>
delete
</button>
)}
</Mutation>
```
Correct:
```tsx
<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:
```tsx
import { mutationOptions } from '@suspensive/react-query-4'
```
Correct:
```tsx
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.