Actions and useTransition: Async Form Handling
Server Actions and useTransition form the backbone of React 19's approach to async operations. Server Actions are functions that run on the server and can be called from the client—they're a new form of RPC (remote procedure call) integrated directly into React. useTransition marks an async operation as non-blocking, keeping the UI responsive while the operation completes. Together, they let you write form submissions, data mutations, and API calls that feel responsive and handle errors gracefully.
Before React 19, async operations blocked the UI: a form submission would disable inputs, show a spinner, and wait. Now with useTransition, long-running operations can update the UI incrementally, prefetch data, and revert optimistically if something fails. This pattern—called "progressive enhancement"—is the foundation of modern React architecture.
Understanding Server Actions
A Server Action is a function marked with 'use server' that runs exclusively on the server. You can call it from the client like a regular function, and React handles the network communication:
// actions.js (Server)
'use server';
import { db } from './db';
export async function createComment(postId, content) {
// This runs ONLY on the server
const user = await getAuthenticatedUser();
if (!user) {
throw new Error('Not authenticated');
}
const comment = await db.comments.create({
postId,
userId: user.id,
content,
});
return comment;
}
Now call it from the client:
// PostPage.jsx (Client)
import { createComment } from './actions';
export function PostPage({ postId }) {
const [isPending, startTransition] = useTransition();
const handleSubmitComment = async (content) => {
startTransition(async () => {
try {
const comment = await createComment(postId, content);
console.log('Comment created:', comment);
} catch (error) {
console.error('Failed to create comment:', error);
}
});
};
return (
<form onSubmit={(e) => handleSubmitComment(e.target.content.value)}>
<textarea name="content" placeholder="Your comment..." />
<button disabled={isPending}>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
);
}
React serializes the arguments, sends them to the server, and returns the result. The server never exposes its implementation; only the function signature is visible to the client.
useTransition: Non-Blocking State Updates
useTransition marks a state update as low-priority, so the browser can remain responsive to user input while the update happens in the background. This is essential for form submission, search, and any operation that shouldn't freeze the page:
import { useTransition, useState } from 'react';
import { updateUserProfile } from './actions';
export function ProfileForm({ user }) {
const [isPending, startTransition] = useTransition();
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
// Mark the state update as non-blocking
startTransition(async () => {
const formData = new FormData(e.target);
try {
const result = await updateUserProfile(Object.fromEntries(formData));
if (result.errors) {
setErrors(result.errors);
} else {
// Profile updated successfully
}
} catch (error) {
setErrors({ general: 'An error occurred' });
}
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" defaultValue={user.name} />
{errors.name && <span className="error">{errors.name}</span>}
<input name="email" defaultValue={user.email} />
{errors.email && <span className="error">{errors.email}</span>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
The key difference: without useTransition, the entire page blocks while the server responds. With it, the page stays responsive—you can type, scroll, click buttons, and interact normally while the profile update happens in the background.
Combining useOptimistic with useTransition
For the best UX, combine useOptimistic (instant feedback) with useTransition (non-blocking operation):
import { useOptimistic, useTransition } from 'react';
import { deletePost } from './actions';
export function Post({ post }) {
const [optimisticPost, optimisticDelete] = useOptimistic(
post,
(state) => ({ ...state, deleted: true })
);
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
// Show deleted state immediately
optimisticDelete();
startTransition(async () => {
try {
await deletePost(post.id);
// If successful, the optimistic state is correct
} catch (error) {
// If failed, useOptimistic reverts to the previous state
console.error('Failed to delete:', error);
}
});
};
if (optimisticPost.deleted) {
return <p className="deleted">Post has been deleted</p>;
}
return (
<article>
<h2>{optimisticPost.title}</h2>
<p>{optimisticPost.content}</p>
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</article>
);
}
The user sees the post disappear instantly. While the server processes the deletion, the page is fully responsive. If the server rejects the deletion, the post reappears.
Progressive Enhancement: HTML Forms with Server Actions
React 19 Server Actions work even without JavaScript—they can be called from HTML forms directly:
// Server
'use server';
export async function subscribeToNewsletter(formData) {
const email = formData.get('email');
await db.newsletter.subscribe(email);
// Redirect or respond
redirect('/subscribed');
}
// Client (works with and without JS)
export function NewsletterForm() {
return (
<form action={subscribeToNewsletter} method="POST">
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</form>
);
}
This form works in old browsers (without React) and modern ones (with React). JavaScript enhances it, but doesn't require it. This is the cornerstone of progressive enhancement.
Error Handling and Retry Logic
Server Actions naturally propagate errors to the client. Handle them with try/catch:
import { useTransition } from 'react';
import { createPayment } from './actions';
export function CheckoutForm() {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState(null);
const handleSubmit = (e) => {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
const result = await createPayment(new FormData(e.target));
console.log('Payment successful:', result);
} catch (error) {
// Network error or server error
setError(error.message || 'Payment failed. Please try again.');
}
});
};
return (
<form onSubmit={handleSubmit}>
{error && <div className="alert alert-error">{error}</div>}
{/* Form fields */}
<button disabled={isPending}>
{isPending ? 'Processing...' : 'Pay'}
</button>
</form>
);
}
For unreliable networks, you can implement retry logic:
const handleSubmit = async (formData) => {
let attempt = 0;
const maxAttempts = 3;
while (attempt < maxAttempts) {
try {
return await createPayment(formData);
} catch (error) {
attempt++;
if (attempt === maxAttempts) throw error;
// Wait before retrying
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
};
Streaming and Partial Updates
For large operations, Server Actions can stream results back to the client incrementally using startTransition:
// Server
'use server';
export async function* streamSearch(query) {
// Yield results as they become available
const results = await search(query);
for (const result of results) {
yield result;
}
}
// Client
import { useTransition } from 'react';
export function Search() {
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (query) => {
startTransition(async () => {
for await (const result of streamSearch(query)) {
// Update UI with each result
setResults(prev => [...prev, result]);
}
});
};
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <p>Searching...</p>}
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</>
);
}
As results stream back, the UI updates incrementally without waiting for all results to arrive. This is critical for search, autocomplete, and other high-latency operations.
Server Actions vs. useCallback + fetch
| Aspect | Server Actions | useCallback + fetch |
|---|---|---|
| Server code location | Colocated in same file | Separate API route |
| Type safety | Automatic (shared types) | Manual validation |
| Network overhead | Optimized by React | Standard HTTP |
| Progressive enhancement | Yes—works without JS | No—requires JS |
| Streaming | Native support | Requires manual setup |
| Setup complexity | Low—just mark with 'use server' | High—need API routes |
Server Actions simplify the common case (calling the server from React) while remaining optional for advanced cases.
Key Takeaways
- Server Actions are functions marked with
'use server'that run exclusively on the server, simplifying client-server communication. useTransitionmarks state updates as non-blocking, keeping the UI responsive during long operations.- Combine
useOptimisticwithuseTransitionfor instant feedback plus non-blocking operations. - Server Actions support progressive enhancement: the same form works with or without JavaScript.
- Pair Server Actions with proper error handling and retry logic for reliability.
Frequently Asked Questions
Do Server Actions require a specific backend framework?
No. Any backend that supports JavaScript or Node.js can host Server Actions (Express, Fastify, Deno, etc.). Full-stack frameworks like Next.js and Remix have built-in support, but it's not required.
Can I call a Server Action from outside React?
Yes, Server Actions are RPC calls, and you can invoke them from anywhere—curl, fetch, another client. React just provides a convenient wrapper.
What if my Server Action takes a long time?
useTransition keeps the UI responsive. For very long operations (> 5 seconds), show progress updates by streaming results back incrementally. Users see partial results and know something is happening.
How do I prevent Server Actions from being called by unauthorized users?
Check authentication in the Server Action. Every Server Action should validate that the user is authenticated and has permission to perform the action:
'use server';
export async function deletePost(postId) {
const user = await getAuthenticatedUser();
if (!user) throw new Error('Unauthorized');
const post = await db.posts.findById(postId);
if (post.userId !== user.id) throw new Error('Forbidden');
await db.posts.delete(postId);
}
Are Server Actions compatible with middleware or rate limiting?
Yes. Server Actions are just HTTP requests under the hood. You can apply middleware, rate limiting, and authentication at the framework level (e.g., Next.js middleware).