Guides
Mutations
Write data in React actions inside forms and transitions, keep the UI responsive with useOptimistic, then invalidate or update the cache.
raf-query has no useMutation. React already has the pieces a mutation needs:
- Actions (
<form action>,startTransition(async () => ...)) run the write and track its pending state. useOptimisticshows the result before the server confirms it, and rolls back on its own when the action ends.useActionStatekeeps the action's result, such as an error message.
The cache's part is what happens after the write: invalidating the queries it affected, or writing the response straight into the cache with setData.
Actions
A mutation is a React action written in the component: get the client with useQueryClient(), await the write, then await a verb that invalidates what the write changed:
function function EditTitle(props: {
post: Post;
}): React.JSX.Element
EditTitle(props: {
post: Post;
}
props: { post: Postpost: Post }) {
const const queryClient: QueryClient<any>queryClient = function useQueryClient(): QueryClientThe client from the nearest
QueryClientProvider.
useQueryClient();
const [const title: stringtitle, const setOptimisticTitle: (action: string | ((pendingState: string) => string)) => voidsetOptimisticTitle] = useOptimistic<string>(passthrough: string): [string, (action: string | ((pendingState: string) => string)) => void] (+1 overload)useOptimistic(props: {
post: Post;
}
props.post: Postpost.Post.title: stringtitle);
return (
<React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form
React.FormHTMLAttributes<HTMLFormElement>.action?: string | ((formData: FormData) => void | Promise<void>) | undefinedaction={async (formData: FormDataformData) => {
const const next: stringnext = var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String(formData: FormDataformData.FormData.get(name: string): FormDataEntryValue | nullThe get() method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead.
get("title"));
const setOptimisticTitle: (action: string | ((pendingState: string) => string)) => voidsetOptimisticTitle(const next: stringnext);
await function updatePost(input: {
slug: string;
title: string;
}): Promise<Post>
updatePost({ slug: stringslug: props: {
post: Post;
}
props.post: Postpost.Post.slug: stringslug, title: stringtitle: const next: stringnext });
await invalidateOne<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(client: ClientFor<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>, factory: (input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, input: {
slug: string;
}): Promise<void>
Mark one query stale: a factory and its full input. If it's read, it
refetches; resolves when that settles.
invalidateOne(const queryClient: QueryClient<any>queryClient, function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug: stringslug: props: {
post: Post;
}
props.post: Postpost.Post.slug: stringslug });
}}
>
<React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.InputHTMLAttributes<HTMLInputElement>.name?: string | undefinedname="title" React.HTMLAttributes<HTMLInputElement>.defaultValue?: string | number | readonly string[] | undefineddefaultValue={const title: stringtitle} />
</React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form>
);
}
updatePost is a plain async function: a TanStack Start server function, a Next.js server action, or a fetch call.
The verb resolves once the queries it invalidated that are on screen have refetched. Awaiting it inside the action keeps the action pending until the fresh data is on screen, so the optimistic value is replaced by the real one without flickering back to the old one in between.
The action decides what to invalidate after the write, so it can use the action's arguments and the write's result. Verbs take factories, groups and where targets, never raw keys:
| Verb | Marks stale |
|---|---|
invalidateOne(queryClient, factory, input) | One query |
invalidateMany(queryClient, factory, part?) | A factory's queries, optionally only matching input |
invalidateMany(queryClient, group) | Every query in a group |
invalidateMany(queryClient, tag, param?) | Every query with a tag |
invalidateMany(queryClient, [target, …]) | Every query any of the targets matches |
invalidateAll(queryClient) | Every query |
Queries on screen refetch at once, the rest when they're next loaded. See Query invalidation.
Several things at once
When a write changes more than one thing, pass the targets to one invalidateMany call:
await function updatePost(input: {
slug: string;
title: string;
}): Promise<Post>
updatePost({ slug: stringslug, title: stringtitle });
await invalidateMany<any, readonly [readonly [(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, {
readonly slug: string;
}], (input: {
page: number;
sort: "newest" | "oldest";
}) => QueryOptions<readonly ["posts", "list", {
page: number;
sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>, Group<readonly ["authors"]>]>(client: QueryClient<any>, targets: readonly [readonly [(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, {
...;
}], (input: {
page: number;
sort: "newest" | "oldest";
}) => QueryOptions<readonly ["posts", "list", {
page: number;
sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>, Group<...>]): Promise<void> (+3 overloads)
Mark queries stale: a factory's (optionally only those whose input
matches partial), a group's (keys that start with its path, whether
the group built them or not), or those a target's where picks. Read
queries refetch now; the rest when next read. Resolves when the
refetches settle.
invalidateMany(const queryClient: QueryClient<any>queryClient, [
[function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug?: string | undefinedslug }], // this post
function postsQuery(input: {
page: number;
sort: "newest" | "oldest";
}): QueryOptions<readonly ["posts", "list", {
page: number;
sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>
postsQuery, // every post list
const authors: Group<readonly ["authors"]>authors, // and the authors' post counts
]);
Their refetches run side by side, and a query that several targets cover refetches once. Awaiting one verb after another serializes them: the second verb's refetches start only once the first's have settled, and the action stays pending for both in a row.
Optimistic updates
useOptimistic holds a value for as long as an action is running. When the action ends, it falls back to the value passed in, which by then is the refetched data. If the action throws, it falls back to the old data: the rollback is automatic.
Add a comment below. The server is slow, and you can make the next request fail to watch the optimistic comment roll back:
The demo's form:
function function AddComment(props: {
comments: ReadonlyArray<ShownComment>;
slug: string;
}): void
AddComment(props: {
comments: ReadonlyArray<ShownComment>;
slug: string;
}
props: {
comments: readonly ShownComment[]comments: interface ReadonlyArray<T>ReadonlyArray<type ShownComment = PostComment & {
pending?: boolean;
}
ShownComment>;
slug: stringslug: string;
}) {
const const queryClient: QueryClient<any>queryClient = function useQueryClient(): QueryClientThe client from the nearest
QueryClientProvider.
useQueryClient();
// Newest first: the new comment shows at the top, marked as saving.
const [const comments: readonly ShownComment[]comments, const addOptimistic: (action: string) => voidaddOptimistic] = useOptimistic<readonly ShownComment[], string>(passthrough: readonly ShownComment[], reducer: (state: readonly ShownComment[], action: string) => readonly ShownComment[]): [readonly ShownComment[], (action: string) => void] (+1 overload)useOptimistic(
props: {
comments: ReadonlyArray<ShownComment>;
slug: string;
}
props.comments: readonly ShownComment[]comments,
(current: readonly ShownComment[]current, body: stringbody: string) => [
{ PostComment.author: stringauthor: "You", PostComment.body: stringbody, PostComment.id: stringid: "new", pending?: boolean | undefinedpending: true },
...current: readonly ShownComment[]current,
],
);
const [const error: string | nullerror, const formAction: (payload: FormData) => voidformAction] = useActionState<string | null, FormData>(action: (state: string | null, payload: FormData) => string | Promise<string | null> | null, initialState: string | null, permalink?: string): [state: string | null, dispatch: (payload: FormData) => void, isPending: boolean] (+1 overload)useActionState(
async (_previous: string | null_previous: string | null, formData: FormDataformData: FormData) => {
const const body: stringbody = var String: StringConstructor
(value?: any) => string
Allows manipulation and formatting of text strings and determination and location of substrings within strings.
String(formData: FormDataformData.FormData.get(name: string): FormDataEntryValue | nullThe get() method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead.
get("body"));
const addOptimistic: (action: string) => voidaddOptimistic(const body: stringbody);
try {
await function addComment(input: {
body: string;
slug: string;
}): Promise<PostComment>
addComment({ body: stringbody, slug: stringslug: props: {
comments: ReadonlyArray<ShownComment>;
slug: string;
}
props.slug: stringslug });
} catch (function (local var) error: unknownerror) {
return function (local var) error: unknownerror instanceof var Error: ErrorConstructorError ? function (local var) error: Errorerror.Error.message: stringmessage : "Something went wrong";
}
await invalidateMany<any, readonly [readonly [(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, {
readonly slug: string;
}]]>(client: QueryClient<any>, targets: readonly [readonly [(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, {
...;
}]]): Promise<void> (+3 overloads)
Mark queries stale: a factory's (optionally only those whose input
matches partial), a group's (keys that start with its path, whether
the group built them or not), or those a target's where picks. Read
queries refetch now; the rest when next read. Resolves when the
refetches settle.
invalidateMany(const queryClient: QueryClient<any>queryClient, [
[function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery, { slug?: string | undefinedslug: props: {
comments: ReadonlyArray<ShownComment>;
slug: string;
}
props.slug: stringslug }],
]);
return null;
},
null,
);
// ...
}Optimistic updates in the cache
useOptimistic covers the component that ran the action. When other parts of the page show the same data (a count in the sidebar, the same item in another list), update the cache instead, so every reader sees it:
async function function renamePost(queryClient: QueryClient, input: {
slug: string;
title: string;
}): Promise<void>
renamePost(
queryClient: QueryClient<any>queryClient: interface QueryClient<TTypes extends RootTypes = any>A query cache. TTypes is its root's types, which factories built with
bindQueryOptions<typeof makeQueryClient>() are checked against. The bare
QueryClient type takes a client of any root.
QueryClient,
input: {
slug: string;
title: string;
}
input: { slug: stringslug: string; title: stringtitle: string },
) {
const const previous: InfiniteData<PostPage, number> | undefinedprevious = getData<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>(client: ClientFor<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>, factory: () => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>): InfiniteData<...> | undefinedA query's cached data (before select), or undefined.
getData(queryClient: QueryClient<any>queryClient, function feedQuery(): InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>feedQuery);
setData<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>(client: ClientFor<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>, factory: () => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>, updater: Updater<...>): void (+1 overload)Write a query's data, as if a request had just returned it. updater
gets the current data, or undefined if there is none. A factory that
takes no input (or an optional one) skips the input:
setData(client, todosQuery, updater).
setData(queryClient: QueryClient<any>queryClient, function feedQuery(): InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>feedQuery, (data: InfiniteData<PostPage, number> | undefineddata) => ({
InfiniteData<PostPage, number>.pageParams: readonly number[]pageParams: data: InfiniteData<PostPage, number> | undefineddata?.InfiniteData<PostPage, number>.pageParams: readonly number[] | undefinedpageParams ?? [],
// Patch the post in whichever page holds it.
InfiniteData<PostPage, number>.pages: readonly PostPage[]pages: (data: InfiniteData<PostPage, number> | undefineddata?.InfiniteData<PostPage, number>.pages: readonly PostPage[] | undefinedpages ?? []).ReadonlyArray<PostPage>.map<{
items: Post[];
nextCursor: number | null;
}>(callbackfn: (value: PostPage, index: number, array: readonly PostPage[]) => {
items: Post[];
nextCursor: number | null;
}, thisArg?: any): {
items: Post[];
nextCursor: number | null;
}[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
map((page: PostPagepage) => ({
...page: PostPagepage,
items: Post[]items: page: PostPagepage.PostPage.items: readonly Post[]items.ReadonlyArray<Post>.map<Post>(callbackfn: (value: Post, index: number, array: readonly Post[]) => Post, thisArg?: any): Post[]Calls a defined callback function on each element of an array, and returns an array that contains the results.
map((post: Postpost) =>
post: Postpost.Post.slug: stringslug === input: {
slug: string;
title: string;
}
input.slug: stringslug ? { ...post: Postpost, Post.title: stringtitle: input: {
slug: string;
title: string;
}
input.title: stringtitle } : post: Postpost,
),
})),
}));
try {
await function updatePost(input: {
slug: string;
title: string;
}): Promise<Post>
updatePost(input: {
slug: string;
title: string;
}
input);
} catch (function (local var) error: unknownerror) {
setData<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>(client: ClientFor<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>, factory: () => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>, updater: Updater<...>): void (+1 overload)Write a query's data, as if a request had just returned it. updater
gets the current data, or undefined if there is none. A factory that
takes no input (or an optional one) skips the input:
setData(client, todosQuery, updater).
setData(queryClient: QueryClient<any>queryClient, function feedQuery(): InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>feedQuery, const previous: InfiniteData<PostPage, number> | undefinedprevious ?? { InfiniteData<PostPage, number>.pageParams: readonly number[]pageParams: [], InfiniteData<PostPage, number>.pages: readonly PostPage[]pages: [] });
throw function (local var) error: unknownerror;
} finally {
await invalidateMany<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>(client: ClientFor<() => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>>, factory: () => InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>, partial?: undefined): Promise<void> (+3 overloads)Mark queries stale: a factory's (optionally only those whose input
matches partial), a group's (keys that start with its path, whether
the group built them or not), or those a target's where picks. Read
queries refetch now; the rest when next read. Resolves when the
refetches settle.
invalidateMany(queryClient: QueryClient<any>queryClient, function feedQuery(): InfiniteQueryOptions<readonly ["posts", "feed"], PostPage, number, InfiniteData<PostPage, number>, DefaultTypes>feedQuery);
}
}
feedQuery is paged, so its data is { pages, pageParams }: the updater patches the post's title in whichever page holds it and keeps pageParams as they are.
This is more code, and you're responsible for the rollback. Reach for it only when useOptimistic can't reach the readers that need the update.
Updates from mutation responses
When the server responds with the updated object, write it into the cache instead of refetching:
const const updated: Postupdated = await function updatePost(input: {
slug: string;
title: string;
}): Promise<Post>
updatePost({ slug: stringslug, title: stringtitle });
setData<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(client: ClientFor<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>, factory: (input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, input: {
slug: string;
}, updater: Updater<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): void (+1 overload)
Write a query's data, as if a request had just returned it. updater
gets the current data, or undefined if there is none. A factory that
takes no input (or an optional one) skips the input:
setData(client, todosQuery, updater).
setData(const queryClient: QueryClient<any>queryClient, function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug: stringslug }, const updated: Postupdated);setData(queryClient, factory, input, updater) takes a value or an updater, (previous) => next, where previous is undefined when the cache has no data. A factory without an input leaves it out: setData(queryClient, feedQuery, updater). Readers re-render with the new data at once, without suspending. The entry is created if it doesn't exist yet, and data written with setData counts as just fetched, so it's fresh for staleTime. getData(queryClient, factory, input) reads the cached data back.
Keep updaters immutable: return new objects and arrays. Structural sharing compares the new data with the old, so mutating previous in place can hide the change from readers.
Error messages
Wrap the action in useActionState and return the caught error, as the demo does. The returned value is the action's state, so the form can render it next to the input. An error thrown out of an action (instead of returned) goes to the nearest error boundary.
Pending state
Inside a form, useFormStatus() gives a submit button the form's pending state. Outside one, useTransition returns isPending for the actions you start with its startTransition. Either way, the pending state lasts until the awaited verb's refetches settle:
const const queryClient: QueryClient<any>queryClient = function useQueryClient(): QueryClientThe client from the nearest
QueryClientProvider.
useQueryClient();
const [const isPending: booleanisPending, const startTransition: React.TransitionStartFunctionstartTransition] = function useTransition(): [boolean, React.TransitionStartFunction]Allows components to avoid undesirable loading states by waiting for content to load
before transitioning to the next screen. It also allows components to defer slower,
data fetching updates until subsequent renders so that more crucial updates can be
rendered immediately.
useTransition();
return (
<React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button
React.ButtonHTMLAttributes<HTMLButtonElement>.disabled?: boolean | undefineddisabled={const isPending: booleanisPending}
React.DOMAttributes<HTMLButtonElement>.onClick?: React.MouseEventHandler<HTMLButtonElement> | undefinedonClick={() => {
const startTransition: React.TransitionStartFunction
(callback: React.TransitionFunction) => void
State updates caused inside the callback are allowed to be deferred.
startTransition(async () => {
await function deletePostApi(slug: string): Promise<void>deletePostApi(props: {
slug: string;
}
props.slug: stringslug);
// The post lists, and the authors' post counts
await invalidateMany<any, readonly [(input: {
page: number;
sort: "newest" | "oldest";
}) => QueryOptions<readonly ["posts", "list", {
page: number;
sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>, Group<readonly ["authors"]>]>(client: QueryClient<any>, targets: readonly [(input: {
page: number;
sort: "newest" | "oldest";
}) => QueryOptions<readonly ["posts", "list", {
page: number;
sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>, Group<readonly string[]>]): Promise<void> (+3 overloads)
Mark queries stale: a factory's (optionally only those whose input
matches partial), a group's (keys that start with its path, whether
the group built them or not), or those a target's where picks. Read
queries refetch now; the rest when next read. Resolves when the
refetches settle.
invalidateMany(const queryClient: QueryClient<any>queryClient, [function postsQuery(input: {
page: number;
sort: "newest" | "oldest";
}): QueryOptions<readonly ["posts", "list", {
page: number;
sort: "newest" | "oldest";
}], readonly Post[], readonly Post[], DefaultTypes>
postsQuery, const authors: Group<readonly ["authors"]>authors]);
});
}}
>
Delete
</React.JSX.IntrinsicElements.button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>button>
);
Mutations that navigate
After creating a post, you usually navigate to it. Navigate after the action resolves, and the destination's loader finds the cache already up to date. If the destination's loader starts the new post with swr, you can skip invalidating it entirely: a cache miss always fetches.
Serial mutations
Actions run concurrently by default. When writes must happen in order (each depends on the server's response to the previous one), queue them in your own code, or disable the form while one is pending.