Skip to main content

Suspense in Data Mutations: Coordinating Forms with Server Actions

Suspense started with data fetching, but Server Actions in Next.js 14+ extend it to mutations (form submissions, updates, deletes). Instead of managing form submission state with useState(loading), you can use Suspense with Server Actions to coordinate form UI, show loading states, and handle errors—all with less boilerplate.

This article covers Server Actions, optimistic updates, and using Suspense to coordinate form submission states across your app.

What Are Server Actions?

A Server Action is a special async function marked with 'use server' that runs only on the server, even when called from a Client Component. It's a simpler way to handle form submissions and mutations without building a separate API:

// app/lib/actions.js
'use server';

export async function createPost(formData) {
const title = formData.get('title');
const body = formData.get('body');

// This runs on the server; no API endpoint needed
const post = await db.posts.create({ title, body });
return post;
}

// app/posts/new/page.js
'use client';

import { createPost } from '@/lib/actions';
import { useFormStatus } from 'react-dom';

function SubmitButton() {
const { pending } = useFormStatus();

return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}

export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="body" placeholder="Post content" required />
<SubmitButton />
</form>
);
}

When the user clicks "Create Post," the createPost Server Action runs on the server. useFormStatus() tracks the submission state (pending), allowing you to disable the button and show "Creating...".

Suspense with Form Mutations

For more complex forms, use useTransition (which works with Server Actions) instead of useFormStatus:

'use client';

import { createPost } from '@/lib/actions';
import { Suspense, useTransition, useState } from 'react';

function PostForm() {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState(null);

const handleSubmit = async (e) => {
e.preventDefault();
setError(null);

const formData = new FormData(e.target);

startTransition(async () => {
try {
const result = await createPost(formData);
// On success, reset form or redirect
e.target.reset();
} catch (err) {
setError(err.message);
}
});
};

return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Post title" required />
<textarea name="body" placeholder="Post content" required />

{error && <div className="error">{error}</div>}

<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}

export default function NewPostPage() {
return (
<Suspense fallback={<div>Loading form...</div>}>
<PostForm />
</Suspense>
);
}

startTransition runs the Server Action and marks the update as non-blocking. While isPending is true, the button is disabled and shows "Creating...". If the action throws, the error is caught and displayed.

Optimistic Updates: Instant Feedback

Optimistic updates show the expected result immediately (before the server confirms) to make the app feel responsive. If the server rejects, you revert:

'use client';

import { updatePostStatus } from '@/lib/actions';
import { useOptimistic, useTransition } from 'react';

function Post({ post, onUpdate }) {
const [optimisticPost, addOptimisticPost] = useOptimistic(post, (state, newStatus) => ({
...state,
status: newStatus,
}));

const [, startTransition] = useTransition();

const handleStatusChange = (newStatus) => {
// Immediately show the new status (optimistic)
addOptimisticPost(newStatus);

// Then submit to server
startTransition(async () => {
try {
const result = await updatePostStatus(post.id, newStatus);
onUpdate(result); // Update if server returns different data
} catch (err) {
// On error, revert to original state (useOptimistic handles this)
console.error('Update failed:', err);
}
});
};

return (
<article>
<h1>{optimisticPost.title}</h1>
<p>Status: {optimisticPost.status}</p>

<select value={optimisticPost.status} onChange={e => handleStatusChange(e.target.value)}>
<option>draft</option>
<option>published</option>
<option>archived</option>
</select>
</article>
);
}

useOptimistic applies an optimistic update immediately. If the Server Action succeeds, the server response overrides it (no flicker). If it fails, React reverts to the previous state automatically.

Coordinating Multiple Forms with Suspense

When multiple forms update the same data, use Suspense to coordinate them:

// app/lib/actions.js
'use server';

export async function updateUserName(userId, name) {
await db.users.update(userId, { name });
revalidatePath(`/users/${userId}`); // Revalidate cache
}

export async function updateUserEmail(userId, email) {
await db.users.update(userId, { email });
revalidatePath(`/users/${userId}`);
}

// app/users/[id]/edit/page.js
'use client';

import { updateUserName, updateUserEmail } from '@/lib/actions';
import { useState, useTransition } from 'react';

function UserEditPage({ user }) {
const [isPending, startTransition] = useTransition();

const handleNameChange = (name) => {
startTransition(() => updateUserName(user.id, name));
};

const handleEmailChange = (email) => {
startTransition(() => updateUserEmail(user.id, email));
};

return (
<div>
<input
type="text"
defaultValue={user.name}
onChange={e => handleNameChange(e.target.value)}
disabled={isPending}
/>

<input
type="email"
defaultValue={user.email}
onChange={e => handleEmailChange(e.target.value)}
disabled={isPending}
/>

{isPending && <div>Saving...</div>}
</div>
);
}

Both forms update the same user. A single isPending state disables both while either is saving, preventing concurrent updates that might conflict.

Streaming Form Responses with Suspense

For long-running operations (uploads, batch processing), stream the response back using Suspense:

// app/lib/actions.js
'use server';

export async function processLargeFile(formData) {
const file = formData.get('file');

// Stream processing updates
return new Promise((resolve, reject) => {
let processed = 0;
const total = file.size;

const interval = setInterval(() => {
processed += 1024;
if (processed >= total) {
clearInterval(interval);
resolve({ success: true, size: total });
}
}, 100);
});
}

// app/upload/page.js
'use client';

import { processLargeFile } from '@/lib/actions';
import { useTransition } from 'react';
import { useOptimistic } from 'react';

function UploadPage() {
const [, startTransition] = useTransition();
const [progress, setProgress] = useOptimistic(0);

const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);

startTransition(async () => {
const result = await processLargeFile(formData);
setProgress(100);
});
};

return (
<form onSubmit={handleSubmit}>
<input type="file" name="file" required />
<button type="submit">Upload</button>

{progress > 0 && (
<progress value={progress} max={100} />
)}
</form>
);
}

As the server processes the file, you update the progress bar. Server Actions support streaming, making long operations feel responsive.

Error Handling in Server Actions

Errors in Server Actions should be handled explicitly:

// app/lib/actions.js
'use server';

export async function createPost(formData) {
const title = formData.get('title');

if (!title) {
throw new Error('Title is required');
}

try {
const post = await db.posts.create({ title });
return post;
} catch (err) {
if (err.code === 'DUPLICATE_TITLE') {
throw new Error('A post with this title already exists');
}
throw new Error('Failed to create post');
}
}

// app/posts/new/page.js
'use client';

import { createPost } from '@/lib/actions';
import { useTransition, useState } from 'react';

function NewPostPage() {
const [, startTransition] = useTransition();
const [error, setError] = useState(null);

const handleSubmit = (e) => {
e.preventDefault();
setError(null);

startTransition(async () => {
try {
await createPost(new FormData(e.target));
// Success: redirect or show message
} catch (err) {
setError(err.message);
}
});
};

return (
<form onSubmit={handleSubmit}>
{/* form fields */}
{error && <div className="error">{error}</div>}
<button type="submit">Create</button>
</form>
);
}

Errors thrown in Server Actions are caught on the client and can be displayed to the user.

Pattern: Progressive Form Submission

For forms with many fields, submit progressively (save as user types):

'use client';

import { saveDraft } from '@/lib/actions';
import { useTransition } from 'react';

function ArticleEditor() {
const [, startTransition] = useTransition();
const [isSaved, setIsSaved] = useState(false);

const handleChange = (formData) => {
startTransition(async () => {
try {
await saveDraft(formData);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 2000); // Show "Saved" for 2s
} catch (err) {
console.error('Save failed:', err);
}
});
};

return (
<form>
<textarea
onChange={e => {
const formData = new FormData();
formData.set('body', e.target.value);
handleChange(formData);
}}
placeholder="Write your article..."
/>

{isSaved && <span style={{ color: 'green' }}>Saved</span>}
</form>
);
}

Each keystroke saves the draft to the server. The Server Action (saveDraft) runs quietly in the background, and a "Saved" indicator shows success.

Real-World Example: Multi-Step Form

Here's a complete multi-step form with Suspense and Server Actions:

// app/lib/actions.js
'use server';

export async function submitStep1(formData) {
const name = formData.get('name');
if (!name) throw new Error('Name is required');

// Save step 1 to session or temporary storage
return { step: 1, name };
}

export async function submitStep2(formData) {
const email = formData.get('email');
if (!email.includes('@')) throw new Error('Valid email required');

// Save step 2
return { step: 2, email };
}

// app/form/page.js
'use client';

import { submitStep1, submitStep2 } from '@/lib/actions';
import { useState, useTransition } from 'react';

function MultiStepForm() {
const [step, setStep] = useState(1);
const [data, setData] = useState({});
const [error, setError] = useState(null);
const [, startTransition] = useTransition();

const handleNextStep = async (formData) => {
setError(null);

startTransition(async () => {
try {
if (step === 1) {
const result = await submitStep1(formData);
setData({ ...data, ...result });
setStep(2);
} else if (step === 2) {
const result = await submitStep2(formData);
setData({ ...data, ...result });
// Submit entire form
console.log('Form complete:', { ...data, ...result });
}
} catch (err) {
setError(err.message);
}
});
};

return (
<div>
{step === 1 && (
<form onSubmit={e => {
e.preventDefault();
handleNextStep(new FormData(e.target));
}}>
<h2>Step 1: Personal Info</h2>
<input name="name" placeholder="Your name" required />
<button type="submit">Next</button>
</form>
)}

{step === 2 && (
<form onSubmit={e => {
e.preventDefault();
handleNextStep(new FormData(e.target));
}}>
<h2>Step 2: Contact</h2>
<input name="email" type="email" placeholder="Your email" required />
<button type="submit">Submit</button>
</form>
)}

{error && <div className="error">{error}</div>}
</div>
);
}

Server Actions handle validation and storage at each step, keeping form logic server-side.

Key Takeaways

  • Server Actions replace API endpoints: Mark functions with 'use server' to run on the server from Client Components.
  • useTransition() tracks submission state: Use startTransition() to run Server Actions and track isPending.
  • useOptimistic() provides instant feedback: Show the expected result immediately; revert on error.
  • Coordinate multiple forms with shared isPending: Prevent concurrent updates to the same data.
  • Stream long operations: Server Actions support streaming responses for progress updates.

Frequently Asked Questions

Can I use Server Actions without Next.js?

Server Actions are a Next.js feature. Other frameworks use different patterns (tRPC, Blitz, Remix actions).

What's the difference between Server Actions and API routes?

Server Actions are simpler: no boilerplate, automatic serialization, direct access to server resources. API routes are more flexible but require more code. Use Server Actions for forms; use API routes for complex APIs.

How do I revalidate data after a mutation?

Use revalidatePath() or revalidateTag() in the Server Action:

export async function createPost(formData) {
const post = await db.posts.create({ ...formData });
revalidatePath('/posts'); // Revalidate /posts cache
return post;
}

Can I use Server Actions in async components?

Yes. Async Server Components can call other Server Actions:

async function AsyncForm() {
const handleSubmit = (formData) => {
submitForm(formData); // Direct call in async context
};

return <form action={handleSubmit}>...</form>;
}

How do I handle CSRF protection with Server Actions?

Next.js automatically includes CSRF tokens in forms. No manual setup needed.

Further Reading