Skip to main content

Handling Mutations with SWR

SWR is built for reads (GET requests); mutations (POST, PUT, DELETE) require manual orchestration. Unlike RTK Query's mutation endpoints, SWR leaves mutation logic to you: fetch with a method, validate the response, then call mutate() to revalidate related queries. This flexibility is both a strength (no boilerplate) and a weakness (you write the same patterns repeatedly). Mastering SWR mutations means understanding when to revalidate, how to implement optimistic updates safely, and how to structure forms for resilience.

Basic Mutation Pattern with SWR

SWR doesn't provide a useMutation hook, so you manually fetch() and then mutate():

import useSWR from 'swr';

const fetcher = (url) =>
fetch(url).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});

export function CreatePost() {
const { mutate } = useSWR('/api/posts', fetcher);
const [isSubmitting, setIsSubmitting] = useState(false);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);

const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;

try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});

if (!response.ok) {
throw new Error(`Failed to create post: ${response.statusText}`);
}

const newPost = await response.json();
console.log('Created:', newPost);

// Revalidate the posts list
mutate();

// Reset form
e.currentTarget.reset();
} catch (error) {
console.error('Mutation error:', error);
// Show error toast to user
} finally {
setIsSubmitting(false);
}
}

return (
<form onSubmit={handleSubmit}>
<input type="text" name="title" placeholder="Post title" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</button>
</form>
);
}

Key points:

  • Manage isSubmitting state yourself
  • Fetch manually (SWR doesn't provide a mutation hook)
  • Call mutate() after success to revalidate
  • Handle errors explicitly

Optimistic Updates with SWR

Show the new item immediately before the server confirms. Use mutate(newData, false) to update the cache without refetching:

export function CreatePost() {
const { data: posts = [], mutate } = useSWR('/api/posts', fetcher);
const [isSubmitting, setIsSubmitting] = useState(false);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);

const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;

// Create optimistic post
const optimisticPost = {
id: `temp-${Date.now()}`,
title,
createdAt: new Date().toISOString(),
};

// Update UI immediately
mutate([...posts, optimisticPost], false);

try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});

if (!response.ok) throw new Error('Failed to create');

const newPost = await response.json();

// Sync with server truth
mutate(
posts.map((p) =>
p.id === optimisticPost.id ? newPost : p
),
false
);

e.currentTarget.reset();
} catch (error) {
console.error('Mutation error:', error);

// Rollback: revalidate from server
mutate();

// Show error to user
} finally {
setIsSubmitting(false);
}
}

return (
<form onSubmit={handleSubmit}>
<input type="text" name="title" placeholder="Post title" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create'}
</button>
</form>
);
}

The user sees the new post immediately, even before the server responds. If the request fails, mutate() alone refetches from the server, rolling back the optimistic update.

Update and Delete Mutations

Updating requires fetching the resource first, then mutating both the detail view and the list:

export function EditPostForm({ postId }: { postId: number }) {
const { data: post, mutate: mutateDetail } = useSWR(
`/api/posts/${postId}`,
fetcher
);
const { mutate: mutateList } = useSWR('/api/posts', fetcher);
const [isSubmitting, setIsSubmitting] = useState(false);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);

const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;

// Optimistic update for detail view
mutateDetail({ ...post, title }, false);

try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});

if (!response.ok) throw new Error('Failed to update');

const updatedPost = await response.json();
mutateDetail(updatedPost, false);

// Revalidate list to sync
mutateList();
} catch (error) {
console.error('Update error:', error);
mutateDetail(); // Rollback
mutateList();
} finally {
setIsSubmitting(false);
}
}

if (!post) return <div>Loading...</div>;

return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="title"
defaultValue={post.title}
required
/>
<button type="submit" disabled={isSubmitting}>
Update
</button>
</form>
);
}

export function DeletePostButton({ postId }: { postId: number }) {
const { mutate } = useSWR('/api/posts', fetcher);
const [isDeleting, setIsDeleting] = useState(false);

async function handleDelete() {
if (!confirm('Are you sure?')) return;
setIsDeleting(true);

try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'DELETE',
});

if (!response.ok) throw new Error('Failed to delete');

// Revalidate list
mutate((posts) => posts.filter((p) => p.id !== postId), false);
} catch (error) {
console.error('Delete error:', error);
mutate(); // Rollback by refetching
} finally {
setIsDeleting(false);
}
}

return (
<button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
);
}

Error Handling and Validation

Always validate server responses before optimistic updates:

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsSubmitting(true);

const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;

// Validate client-side first
if (!title.trim()) {
setError('Title is required');
setIsSubmitting(false);
return;
}

const optimisticPost = { id: `temp-${Date.now()}`, title };
mutate([...posts, optimisticPost], false);

try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});

const data = await response.json();

if (!response.ok) {
// Server rejected; show validation errors
setError(data.message || 'Failed to create post');
mutate(); // Rollback
return;
}

// Success
mutate((prev) =>
prev.map((p) => (p.id === optimisticPost.id ? data : p))
);
} catch (error) {
setError('Network error. Please try again.');
mutate(); // Rollback
} finally {
setIsSubmitting(false);
}
}

Mutation Hook Pattern

Create a reusable mutation hook to avoid repetition:

function useMutation<TData, TArgs>(
url: string,
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'POST'
) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

async function trigger(args: TArgs): Promise<TData> {
setIsLoading(true);
setError(null);

try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
});

if (!response.ok) {
const data = await response.json();
throw new Error(data.message || `HTTP ${response.status}`);
}

return await response.json();
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
throw error;
} finally {
setIsLoading(false);
}
}

return { trigger, isLoading, error };
}

// Usage:
export function CreatePost() {
const { mutate } = useSWR('/api/posts', fetcher);
const { trigger: createPost, isLoading } = useMutation('/api/posts');

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const title = e.currentTarget.title.value;

try {
const newPost = await createPost({ title });
mutate();
e.currentTarget.reset();
} catch (error) {
console.error('Failed:', error);
}
}

return (
<form onSubmit={handleSubmit}>
<input type="text" name="title" required />
<button type="submit" disabled={isLoading}>
Create
</button>
</form>
);
}

Key Takeaways

  • SWR mutations are manual: fetch(), then mutate() to revalidate
  • Optimistic updates via mutate(newData, false) make mutations feel instant
  • Always implement rollback: call mutate() alone if the request fails
  • Handle client and server validation separately
  • Create reusable mutation hooks to reduce boilerplate across your codebase

Frequently Asked Questions

Should I always use optimistic updates?

Not for critical operations (e.g., payment, account deletion). For UX, optimistic updates on creates and updates are safe if you validate and rollback correctly. For deletes, ask for confirmation.

Can I make SWR mutations type-safe?

Yes, with generics. Define your mutation hook with <ResponseType, RequestType> generics as shown above.

What if multiple mutations affect the same data?

Revalidate broadly: call mutate() without args to refetch from the server. This is safer than trying to manually sync two mutations.

Does SWR have a built-in mutation hook?

No. SWR v2 added useSWRMutation for a cleaner API:

const { trigger, isMutating } = useSWRMutation('/api/posts', fetcher);
await trigger({ title: 'New Post' });

How do I handle dependent mutations (create, then upload)?

Chain them sequentially in your form handler or use async/await:

const post = await createPost({ title });
await uploadFile(`/api/posts/${post.id}/image`, file);
mutate();

Further Reading