Mutations and Optimistic Updates
useMutation for writes: invalidate what a write changed, update the cache optimistically, and roll back when the server disagrees.
const { mutate, mutateAsync, isPending } = useMutation({ mutationFn, onMutate, onError, onSettled });Queries run themselves when a component renders. Writes must not, so they get their own hook: useMutation({ mutationFn }) returns mutate, which you call from an event handler, plus isPending for disabling the button and variables for showing what is in flight.
Telling the cache it is out of date
After a successful write, every list that contained that row is wrong. Call queryClient.invalidateQueries({ queryKey: ['notes'] }) in onSettled. Matching is by prefix, so that one call also invalidates ['notes', 'abc']; pass exact: true if you do not want that.
Optimistic updates
Waiting for a round trip before the row appears feels slow. The sequence is fixed: in onMutate, call cancelQueries so an in-flight refetch cannot overwrite you, snapshot the current data with getQueryData, write the expected result with setQueryData, and return the snapshot. Whatever you return arrives as the third argument of onError and onSettled, which is how you put the old data back when the server refuses.
What trips people up
mutate returns undefined and never rejects — awaiting it does nothing and a try/catch around it catches nothing. Handle failure in onError, or call mutateAsync when you want a promise. Mutations also do not retry by default, where queries retry three times. And in version 5, setQueryData with undefined is a no-op, so guard the rollback on the snapshot existing.
Example
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
type Note = { id: string; body: string };
async function createNote(body: string): Promise<Note> {
const response = await fetch('https://api.example.com/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Note;
}
export default function AddNote() {
const [body, setBody] = useState('');
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: createNote,
onMutate: async (newBody: string) => {
await queryClient.cancelQueries({ queryKey: ['notes'] }); // no refetch may land mid-update
const previous = queryClient.getQueryData<Note[]>(['notes']);
queryClient.setQueryData<Note[]>(['notes'], (old = []) => [
...old,
{ id: 'optimistic', body: newBody }, // replaced by the server's row on refetch
]);
return { previous }; // arrives as the third argument below
},
onError: (_error, _newBody, onMutateResult) => {
// setQueryData with undefined does nothing, so check before restoring.
if (onMutateResult?.previous) {
queryClient.setQueryData(['notes'], onMutateResult.previous);
}
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['notes'] }),
});
return (
<View style={styles.form}>
<TextInput value={body} onChangeText={setBody} placeholder="New note" style={styles.input} />
<Pressable
disabled={isPending || body.length === 0}
onPress={() => {
mutate(body);
setBody('');
}}
>
<Text>{isPending ? 'Saving' : 'Save'}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
form: { padding: 16, gap: 12 },
input: { borderWidth: 1, borderColor: '#999', borderRadius: 6, padding: 12 },
});When to use it
- Save a form and have the new row appear in the list without a second manual fetch.
- Make a like button respond on tap and undo itself if the request fails.
- Disable a submit button for exactly as long as the write is in flight.
- Refresh every list affected by one delete with a single invalidate call.
More examples
A delete, with invalidation only
The version to write first for any write: no optimism, one invalidate, correct by construction. On screen the row stays until the server confirms, then the list refreshes itself. Add optimistic handling later, only where the delay is actually noticeable.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pressable, StyleSheet, Text } from 'react-native';
async function deleteNote(id: string): Promise<void> {
const response = await fetch(`https://api.example.com/notes/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
}
export default function DeleteButton({ id }: { id: string }) {
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: deleteNote,
// Prefix match: this invalidates ['notes'] and ['notes', id] together.
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notes'] }),
});
return (
<Pressable disabled={isPending} onPress={() => mutate(id)} style={styles.button}>
<Text>{isPending ? 'Deleting' : 'Delete'}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({ button: { padding: 12 } });mutateAsync when the handler needs to await
Reach for mutateAsync when the next step depends on the result — navigating after a successful sign-in, or chaining a second request. It is the only form that rejects, so it is the only form a try/catch can handle; mutate's failures go to onError instead.
import { useMutation } from '@tanstack/react-query';
import { Alert } from 'react-native';
import { router } from 'expo-router';
type Credentials = { email: string; password: string };
async function signIn(credentials: Credentials): Promise<{ token: string }> {
const response = await fetch('https://api.example.com/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) throw new Error('Those details did not work');
return (await response.json()) as { token: string };
}
export function useSignIn() {
const { mutateAsync, isPending } = useMutation({ mutationFn: signIn });
async function submit(credentials: Credentials) {
try {
// mutateAsync rejects on failure, unlike mutate.
await mutateAsync(credentials);
router.replace('/home');
} catch (e) {
Alert.alert('Sign in failed', (e as Error).message);
}
}
return { submit, isPending };
}Showing the pending row without touching the cache
The simpler half of optimistic UI: the cache is never edited, so there is nothing to roll back. On screen a faded row appears under the list while the write is in flight and is replaced by the real row when the refetch lands. Use it when one component owns both the list and the form.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FlatList, StyleSheet, Text } from 'react-native';
type Note = { id: string; body: string };
export default function NoteListWithPending({ notes }: { notes: Note[] }) {
const queryClient = useQueryClient();
const { isPending, variables } = useMutation({
mutationFn: async (body: string) => {
const response = await fetch('https://api.example.com/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Note;
},
// Returning the promise keeps isPending true until the refetch finishes.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['notes'] }),
});
return (
<FlatList
data={notes}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text style={styles.row}>{item.body}</Text>}
ListFooterComponent={
// variables is whatever was passed to mutate, so the new text is already here.
isPending ? <Text style={styles.pending}>{variables}</Text> : null
}
/>
);
}
const styles = StyleSheet.create({
row: { padding: 16 },
pending: { padding: 16, opacity: 0.5 },
});
Discussion