patterntypescriptreactModerate
Optimistic form submission — immediate UI with error rollback
Viewed 0 times
optimistic UIform submissiononMutaterollback on errorsetQueryData optimistictemporary IDinstant feedback
Problem
Forms that disable the submit button and wait for the server response before updating the UI feel slow and unresponsive. For creates and updates, the server usually succeeds — optimistic submission makes the UI instant and reserves rollback for the rare error case.
Solution
Apply the change to local state immediately, then sync with server and rollback on failure:
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Comment { id: number; text: string; authorId: number; }
function CommentBox({ postId }: { postId: number }) {
const [text, setText] = useState('');
const queryClient = useQueryClient();
const addComment = useMutation({
mutationFn: (newText: string) =>
fetch(
method: 'POST',
body: JSON.stringify({ text: newText }),
}).then((r) => r.json()),
onMutate: async (newText) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previous = queryClient.getQueryData<Comment[]>(['comments', postId]);
// Add a temporary optimistic comment with a fake id
const optimistic: Comment = { id: Date.now(), text: newText, authorId: -1 };
queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) => [
...old,
optimistic,
]);
setText('');
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['comments', postId], context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
return (
<form onSubmit={(e) => { e.preventDefault(); addComment.mutate(text); }}>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit" disabled={addComment.isPending}>Post</button>
</form>
);
}
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Comment { id: number; text: string; authorId: number; }
function CommentBox({ postId }: { postId: number }) {
const [text, setText] = useState('');
const queryClient = useQueryClient();
const addComment = useMutation({
mutationFn: (newText: string) =>
fetch(
/api/posts/${postId}/comments, {method: 'POST',
body: JSON.stringify({ text: newText }),
}).then((r) => r.json()),
onMutate: async (newText) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previous = queryClient.getQueryData<Comment[]>(['comments', postId]);
// Add a temporary optimistic comment with a fake id
const optimistic: Comment = { id: Date.now(), text: newText, authorId: -1 };
queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) => [
...old,
optimistic,
]);
setText('');
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['comments', postId], context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
return (
<form onSubmit={(e) => { e.preventDefault(); addComment.mutate(text); }}>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit" disabled={addComment.isPending}>Post</button>
</form>
);
}
Why
The optimistic comment appears instantly with a temporary ID. On success, invalidateQueries replaces it with the server-generated comment. On error, the previous snapshot is restored and the user sees the original state with an error to retry.
Gotchas
- Fake temporary IDs (Date.now()) can cause React key conflicts if the list is invalidated and refetched before the mutation settles
- Clear the input in onMutate rather than onSuccess so the user can type the next message while the request is in flight
- If the server returns a 4xx validation error, the rollback gives no error message by default — add toast or inline error feedback in onError
- Optimistic UI is not suitable for irreversible actions (payments, deletions) — require explicit confirmation instead
Revisions (0)
No revisions yet.