Skip to content

raf-query is early. The API can still change between minor releases, so use it at your own risk.

raf-query

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:

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(): QueryClient

The 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 | null

The 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:

VerbMarks 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(): QueryClient

The 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 | null

The 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<...> | undefined

A 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.

@paramcallbackfn — A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg — An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
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.

@paramcallbackfn — A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg — An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
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(): QueryClient

The 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.

@paramcallback — A function which causes state updates that can 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.