Managing Mutation State: Loading, Errors, Success
Managing mutation state is about orchestrating the UI's response to each phase of a write operation. From disabling buttons during network requests, to displaying targeted error messages, to confirming success, every state transition is an opportunity to guide the user. React Query's isPending, isError, isSuccess, and isIdle flags combine to form a complete state machine—the foundation of predictable, accessible forms and async operations.
Understanding the Mutation State Machine
Each mutation occupies exactly one state at a time. Tracking these transitions ensures your UI stays coherent and never leaves the user guessing. React Query provides boolean flags that you can compose into conditional rendering, button disabling, and error displays.
import { useMutation } from '@tanstack/react-query';
export default function LoginForm() {
const mutation = useMutation({
mutationFn: async (credentials) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
});
if (!response.ok) throw new Error(`Login failed: ${response.status}`);
return response.json();
},
});
const handleLogin = (email, password) => {
mutation.mutate({ email, password });
};
return (
<div>
{/* Render conditional UI based on mutation state */}
{mutation.isIdle && <p>Ready to log in.</p>}
{mutation.isPending && <p>Logging in...</p>}
{mutation.isSuccess && <p>Welcome, {mutation.data.user.name}!</p>}
{mutation.isError && <p style={{ color: 'red' }}>Error: {mutation.error.message}</p>}
<button onClick={() => handleLogin('[email protected]', 'pass')} disabled={mutation.isPending}>
Log In
</button>
</div>
);
}
In this example:
- idle: form is fresh; prompt is shown; button is enabled.
- pending: request is in-flight; loading message replaces prompt; button is disabled.
- success: server responded; success message and user data appear.
- error: request failed; error message displays; button remains enabled so user can retry.
Building Accessible Form UIs with Mutation State
Forms are the most common mutation consumer. Disabling submission buttons, clearing old error messages, and showing inline field-level errors creates a smooth, accessible experience.
export default function SignupForm() {
const [formData, setFormData] = React.useState({ email: '', password: '', name: '' });
const mutation = useMutation({
mutationFn: async (data) => {
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
const error = await res.json();
throw error; // Pass { errors: { email: 'Already taken' } } etc.
}
return res.json();
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate(formData);
};
const fieldError = (fieldName) => {
// Extract field-specific errors from server response
return mutation.error?.errors?.[fieldName];
};
return (
<form onSubmit={handleSubmit}>
<fieldset disabled={mutation.isPending}>
<label>
Name
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</label>
<label>
Email
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
{fieldError('email') && <p style={{ color: 'red' }}>{fieldError('email')}</p>}
</label>
<label>
Password
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</label>
<button type="submit">
{mutation.isPending ? 'Signing up...' : 'Sign Up'}
</button>
</fieldset>
{mutation.isError && !Object.keys(mutation.error?.errors || {}).length && (
<p style={{ color: 'red', marginTop: '1rem' }}>
Signup failed: {mutation.error.message}
</p>
)}
</form>
);
}
Key accessibility wins:
disabled={mutation.isPending}on the<fieldset>prevents accidental double-submissions and signals to screen readers that the form is processing.- Field-level errors (
fieldError(name)) appear below each input so users know exactly what to fix. - Error messages are semantic (not alerts) and update synchronously with mutation state.
Handling Async Chains with State Transitions
Sometimes a mutation's success depends on prior state or side effects. Use the onSuccess and onError callbacks to trigger downstream actions.
export default function DeletePostButton({ postId }) {
const mutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/posts/${postId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
return res.json();
},
onSuccess: (data) => {
// Post deleted; navigate user away
console.log('Post deleted:', data);
window.location.href = '/posts';
},
onError: (error) => {
// Log error for debugging
console.error('Delete error:', error);
},
});
return (
<div>
<button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Deleting...' : 'Delete Post'}
</button>
{mutation.isError && <p>Could not delete: {mutation.error.message}</p>}
</div>
);
}
Toast and Modal Patterns for Success/Error Feedback
Many apps use toast notifications or modals for transient feedback. React Query's state flags integrate cleanly with these patterns.
export default function PublishPostButton({ postId, onPublishSuccess }) {
const mutation = useMutation({
mutationFn: async () => {
const res = await fetch(`/api/posts/${postId}/publish`, { method: 'POST' });
if (!res.ok) throw new Error('Publish failed');
return res.json();
},
});
React.useEffect(() => {
if (mutation.isSuccess) {
// Show success toast; auto-dismiss after 3 seconds
const timer = setTimeout(() => {
mutation.reset();
}, 3000);
return () => clearTimeout(timer);
}
}, [mutation.isSuccess, mutation]);
return (
<>
<button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Publishing...' : 'Publish'}
</button>
{mutation.isSuccess && (
<div role="status" style={{ backgroundColor: '#d4edda', color: '#155724', padding: '1rem' }}>
Post published! View it <a href={`/posts/${mutation.data.id}`}>here</a>.
</div>
)}
{mutation.isError && (
<div role="alert" style={{ backgroundColor: '#f8d7da', color: '#721c24', padding: '1rem' }}>
{mutation.error.message}
</div>
)}
</>
);
}
Using role="status" and role="alert" makes these notifications accessible to screen readers; they announce state changes without requiring user focus.
Chaining Mutations: Sequential Operations
For workflows where one mutation must complete before another begins (e.g., create a post, then upload an image to it), use mutateAsync and chain with await or .then().
export default function CreatePostWithImage() {
const createPostMutation = useMutation({
mutationFn: async (title) => {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error('Failed to create post');
return res.json();
},
});
const uploadImageMutation = useMutation({
mutationFn: async ({ postId, image }) => {
const formData = new FormData();
formData.append('image', image);
const res = await fetch(`/api/posts/${postId}/image`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error('Failed to upload image');
return res.json();
},
});
const handleSubmit = async (title, imageFile) => {
try {
// Step 1: Create post
const post = await createPostMutation.mutateAsync(title);
// Step 2: Upload image to the new post
const imageResult = await uploadImageMutation.mutateAsync({
postId: post.id,
image: imageFile,
});
console.log('Post and image created:', { post, imageResult });
} catch (error) {
console.error('Failed in chain:', error);
}
};
const isLoading = createPostMutation.isPending || uploadImageMutation.isPending;
return (
<button onClick={() => handleSubmit('My Post', new File([], 'image.jpg'))} disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create Post with Image'}
</button>
);
}
Key Takeaways
- Mutation state flags (
isPending,isSuccess,isError,isIdle) form a complete state machine; never render multiple conflicting states simultaneously. - Disable form inputs and buttons with
disabled={mutation.isPending}to prevent double-submission and signal to users that processing is in-flight. - Extract field-level errors from the server response and display them inline for better UX.
- Use
onSuccessandonErrorcallbacks to trigger side effects (navigation, cache updates, toast notifications) after mutations complete. - Chain mutations sequentially with
mutateAsyncwhen one operation depends on another's result. - Use semantic roles (
role="status",role="alert") for error and success messages to improve accessibility.
Frequently Asked Questions
How do I prevent users from submitting the same form twice?
Set disabled={mutation.isPending} on the submit button. This prevents interaction while the request is in-flight. Additionally, you can track submission attempts in state if you want custom behavior.
Should I reset the mutation state after success?
Only if you want to clear the success message or return the form to an idle state. Use mutation.reset() manually (e.g., when closing a modal) or rely on onSuccess callbacks to navigate away. Auto-resetting on success is not always desired.
How do I show different error messages for different HTTP status codes?
Check mutation.error.response.status in your JSX or in onError callbacks. For example: if (mutation.error.response.status === 409) { showConflictError(); }.
Can I run two mutations at the same time?
Yes, call both mutate() methods independently. Each mutation is isolated. The second mutation will not wait for the first unless you explicitly chain with mutateAsync.
What's the difference between onSuccess and handling success with isSuccess?
onSuccess callbacks are ideal for side effects (cache updates, navigation, toast showing). The isSuccess flag is for conditionally rendering UI. Both are useful together: use the callback for logic, use the flag for presentation.