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 with form actions 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.
  • useOptimistic shows the result before the server confirms it, and rolls back on its own when the action ends.
  • useActionState keeps 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.

function 
function EditTitle(props: {
    post: Post;
}): React.JSX.Element
EditTitle
(
props: {
    post: Post;
}
props
: { post: Postpost: Post }) {
const const qc: QueryClient<unknown>qc = function useQueryClient(): QueryClient<unknown>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
String
(formData: FormDataformData.FormData.get(name: string): FormDataEntryValue | nullget("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 const qc: QueryClient<unknown>qc.
QueryClient<unknown>.invalidate<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>(...target: Target<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>): Promise<void>
invalidate
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
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.

await qc.invalidate(...) resolves once every query currently being read has 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.

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 todo below. The server is slow, and you can make the next request fail to watch the optimistic item roll back:

The demo's form:

function 
function AddTodo(props: {
    todos: Todo[];
}): void
AddTodo
(
props: {
    todos: Todo[];
}
props
: { todos: Todo[]todos:
type Todo = {
    id: number;
    pending?: boolean;
    text: string;
}
Todo
[] }) {
const const qc: QueryClient<unknown>qc = function useQueryClient(): QueryClient<unknown>useQueryClient(); const [const todos: Todo[]todos, const addOptimistic: (action: string) => voidaddOptimistic] = useOptimistic<Todo[], string>(passthrough: Todo[], reducer: (state: Todo[], action: string) => Todo[]): [Todo[], (action: string) => void] (+1 overload)useOptimistic(
props: {
    todos: Todo[];
}
props
.todos: Todo[]todos,
(current: Todo[]current, text: stringtext: string) => [...current: Todo[]current, { id: numberid: -1, pending?: boolean | undefinedpending: true, text: stringtext }], ); 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 text: stringtext =
var String: StringConstructor
(value?: any) => string
String
(formData: FormDataformData.FormData.get(name: string): FormDataEntryValue | nullget("text"));
const addOptimistic: (action: string) => voidaddOptimistic(const text: stringtext); try { await function addTodo(text: string): Promise<void>addTodo(const text: stringtext); } 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 const qc: QueryClient<unknown>qc.
QueryClient<unknown>.invalidate<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>(...target: Target<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>): Promise<void>
invalidate
(
function todosQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>
todosQuery
);
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 toggleTodo(id: number): Promise<void>toggleTodo(id: numberid: number) {
	const 
const previous: InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}> | undefined
previous
= const qc: QueryClient<never>qc.
QueryClient<never>.getData<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>(factory: () => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>, input?: undefined): InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}> | undefined
getData
(
function todosQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>
todosQuery
);
const qc: QueryClient<never>qc.
QueryClient<never>.setData<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>(factory: () => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>, input: undefined, updater: Updater<InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>): void
setData
(
function todosQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>
todosQuery
, var undefinedundefined, (
data: InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}> | undefined
data
) => ({
InfiniteData<{ page: { items: readonly Todo[]; nextCursor: null; }; pageParam: number; }>.pageParams: readonly number[]pageParams:
data: InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}> | undefined
data
?.InfiniteData<{ page: { items: readonly Todo[]; nextCursor: null; }; pageParam: number; }>.pageParams: readonly number[] | undefinedpageParams ?? [],
InfiniteData<{ page: { items: readonly Todo[]; nextCursor: null; }; pageParam: number; }>.pages: readonly {
    items: readonly Todo[];
    nextCursor: null;
}[]
pages
: (
data: InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}> | undefined
data
?.
InfiniteData<{ page: { items: readonly Todo[]; nextCursor: null; }; pageParam: number; }>.pages: readonly {
    items: readonly Todo[];
    nextCursor: null;
}[] | undefined
pages
?? []).
ReadonlyArray<{ items: readonly Todo[]; nextCursor: null; }>.map<{
    items: Todo[];
    nextCursor: null;
}>(callbackfn: (value: {
    items: readonly Todo[];
    nextCursor: null;
}, index: number, array: readonly {
    items: readonly Todo[];
    nextCursor: null;
}[]) => {
    items: Todo[];
    nextCursor: null;
}, thisArg?: any): {
    items: Todo[];
    nextCursor: null;
}[]
map
((
page: {
    items: readonly Todo[];
    nextCursor: null;
}
page
) => ({
...
page: {
    items: readonly Todo[];
    nextCursor: null;
}
page
,
items: Todo[]items:
page: {
    items: readonly Todo[];
    nextCursor: null;
}
page
.items: readonly Todo[]items.ReadonlyArray<Todo>.map<Todo>(callbackfn: (value: Todo, index: number, array: readonly Todo[]) => Todo, thisArg?: any): Todo[]map((todo: Todotodo) =>
todo: Todotodo.Todo.id: numberid === id: numberid ? { ...todo: Todotodo, Todo.done: booleandone: !todo: Todotodo.Todo.done: booleandone } : todo: Todotodo, ), })), })); try { await
const api: {
    toggleTodo: (id: number) => Promise<void>;
}
api
.toggleTodo: (id: number) => Promise<void>toggleTodo(id: numberid);
} catch (function (local var) error: unknownerror) { const qc: QueryClient<never>qc.
QueryClient<never>.setData<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>(factory: () => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>, input: undefined, updater: Updater<InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>): void
setData
(
function todosQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>
todosQuery
,
var undefinedundefined,
const previous: InfiniteData<{
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}> | undefined
previous
?? { InfiniteData<{ page: { items: readonly Todo[]; nextCursor: null; }; pageParam: number; }>.pageParams: readonly number[]pageParams: [],
InfiniteData<{ page: { items: readonly Todo[]; nextCursor: null; }; pageParam: number; }>.pages: readonly {
    items: readonly Todo[];
    nextCursor: null;
}[]
pages
: [] },
); throw function (local var) error: unknownerror; } finally { await const qc: QueryClient<never>qc.
QueryClient<never>.invalidate<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>(...target: Target<() => InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>>): Promise<void>
invalidate
(
function todosQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["todos"];
    page: {
        items: readonly Todo[];
        nextCursor: null;
    };
    pageParam: number;
}>>
todosQuery
);
} }

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 });
const qc: QueryClient<never>qc.
QueryClient<never>.setData<(input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>>(factory: (input: {
    slug: string;
}) => QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>, input: {
    slug: string;
}, updater: Updater<Post>): void
setData
(
function postQuery(input: {
    slug: string;
}): QueryOptions<Flat<{
    data: Post;
    key: readonly ["post", string];
}>>
postQuery
, { slug: stringslug }, const updated: Postupdated);

setData takes a value or an updater, (previous) => next. 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.

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:

const [const isPending: booleanisPending, const startTransition: React.TransitionStartFunctionstartTransition] = function useTransition(): [boolean, React.TransitionStartFunction]useTransition();

<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
startTransition
(async () => {
await function deletePost(slug: string): Promise<void>deletePost(
const props: {
    slug: string;
}
props
.slug: stringslug);
await const qc: QueryClient<never>qc.
QueryClient<never>.invalidate<() => InfiniteQueryOptions<Flat<{
    key: readonly ["posts"];
    page: {
        items: Array<{
            slug: string;
        }>;
        nextCursor: null;
    };
    pageParam: number;
}>>>(...target: Target<() => InfiniteQueryOptions<Flat<{
    key: readonly ["posts"];
    page: {
        items: Array<{
            slug: string;
        }>;
        nextCursor: null;
    };
    pageParam: number;
}>>>): Promise<void>
invalidate
(
function postsQuery(): InfiniteQueryOptions<Flat<{
    key: readonly ["posts"];
    page: {
        items: Array<{
            slug: string;
        }>;
        nextCursor: null;
    };
    pageParam: number;
}>>
postsQuery
);
}); }} > 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 invalidating, and the destination's loader finds the cache already up to date. If the destination's loader ensures the new post, you can skip the invalidation for 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.