React 19 Server Actions and Form Handling: Complete Guide
Master React 19 Server Actions for form handling, data mutations, and server-side operations. Learn progressive enhancement, error handling, validation, and best practices with practical examples.
Table of Contents
- Introduction
- What Are Server Actions?
- Setting Up Server Actions
- Basic Server Actions Usage
- Form Handling with Server Actions
- Progressive Enhancement
- Error Handling and Validation
- Optimistic Updates
- Advanced Patterns
- Best Practices
- Common Pitfalls to Avoid
- Conclusion
Introduction
Form handling has long been one of the most complex aspects of building React applications. Traditional approaches require managing form state, handling validation, coordinating API calls, managing loading states, and handling errors—all while ensuring a smooth user experience. React 19’s Server Actions revolutionize this workflow by providing a seamless way to execute server-side code directly from client components, eliminating the need for API routes and simplifying form submissions.
Server Actions represent a paradigm shift in how we think about data mutations in React applications. Instead of creating separate API endpoints and managing fetch calls, you can define server-side functions that execute securely on the server and can be called directly from your React components. This approach reduces boilerplate, improves security by keeping sensitive logic on the server, and enables progressive enhancement—forms work even without JavaScript.
With React 19 and frameworks like Next.js 16+, Server Actions are production-ready and provide powerful features like automatic form validation, built-in error handling, and seamless integration with Server Components. Understanding Server Actions is essential for modern React development, especially when building full-stack applications.
This comprehensive guide will teach you everything you need to know about Server Actions in React 19. You’ll learn how to create and use Server Actions, handle forms effectively, implement progressive enhancement, manage errors gracefully, and follow best practices. By the end, you’ll be able to build robust, performant forms that leverage the full power of React 19’s server-side capabilities.
What Are Server Actions?
Server Actions are asynchronous server-side functions that can be called directly from React components. They execute on the server, have access to server-side resources like databases and file systems, and can be invoked from both Server Components and Client Components. Server Actions provide a type-safe, secure way to handle data mutations without creating separate API routes.
Key Characteristics
Server-Side Execution: Server Actions run exclusively on the server, never in the browser. This means sensitive operations like database queries, authentication checks, and file operations happen securely on the server.
Direct Function Calls: Unlike traditional API calls that require fetch requests and route handlers, Server Actions can be called like regular functions. React handles the serialization and network communication automatically.
Type Safety: When using TypeScript with Server Actions, you get end-to-end type safety from the server function to the client component, catching errors at compile time.
Progressive Enhancement: Forms using Server Actions work without JavaScript enabled, falling back to traditional form submissions while maintaining full functionality when JavaScript is available.
How Server Actions Work
When you call a Server Action from a component, React serializes the function call and sends it to the server over HTTP. The server executes the function, returns the result, and React updates the UI accordingly. This process is transparent to developers—you write code as if calling a regular function, but React handles all the networking complexity.
// Server Action (runs on server)async function createUser(formData: FormData) { 'use server';
const name = formData.get('name') as string; const email = formData.get('email') as string;
// Database operation happens on server const user = await db.users.create({ name, email });
return { success: true, user };}
// Client Component (runs in browser)function UserForm() { return ( <form action={createUser}> <input name="name" required /> <input name="email" type="email" required /> <button type="submit">Create User</button> </form> );}Server Actions vs Traditional API Routes
| Aspect | Server Actions | Traditional API Routes |
|---|---|---|
| Setup | Define function with 'use server' | Create route handler file |
| Calling | Direct function call | fetch() or HTTP client |
| Type Safety | End-to-end TypeScript | Manual type definitions |
| Form Integration | Native <form action> support | Manual form handling |
| Progressive Enhancement | Built-in | Manual implementation |
| Error Handling | Integrated with React | Manual error management |
| Code Location | Co-located with components | Separate API directory |
Server Actions eliminate the need for separate API route files, reducing code duplication and improving maintainability. They’re particularly powerful when combined with Server Components, as explored in our guide on React Server Components vs Client Components.
Setting Up Server Actions
Prerequisites
To use Server Actions, you need:
- React 19+ or Next.js 16+ (which includes React 19)
- Node.js 18+ for server-side execution
- TypeScript (recommended) for type safety
Framework Setup
Next.js 16+ (App Router)
Next.js has built-in support for Server Actions. No additional configuration is required:
"use server";
export async function createUser(formData: FormData) { // Server Action code here}React 19 Standalone
For React 19 applications without Next.js, you’ll need to configure a server that supports Server Actions. This typically involves setting up a React Server Components runtime:
import { renderToPipeableStream } from "react-dom/server";import { createServer } from "http";
// Server setup for Server Actionsconst server = createServer(async (req, res) => { // Handle Server Action requests if (req.method === "POST" && req.url === "/_actions") { // Process Server Action }});⚠️ Note: Server Actions work best with frameworks that provide server-side rendering. While possible to use with standalone React, frameworks like Next.js provide the best developer experience.
Creating Your First Server Action
Server Actions are defined using the 'use server' directive. This directive tells React that the function should execute on the server:
"use server";
import { db } from "@/lib/db";import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) { const title = formData.get("title") as string; const description = formData.get("description") as string;
if (!title || title.trim().length === 0) { return { error: "Title is required" }; }
const todo = await db.todos.create({ title: title.trim(), description: description?.toString() || "", completed: false, });
// Revalidate the todos page to show new data revalidatePath("/todos");
return { success: true, todo };}
export async function deleteTodo(id: string) { await db.todos.delete(id); revalidatePath("/todos");
return { success: true };}File Organization Strategies
✅ Recommended: Organize Server Actions by domain or feature:
app/ actions/ todos.ts # Todo-related actions users.ts # User-related actions comments.ts # Comment-related actions✅ Alternative: Co-locate with components when actions are component-specific:
app/ components/ UserForm.tsx user-actions.ts # Actions specific to UserForm❌ Avoid: Mixing Server Actions with Client Components in the same file:
// ❌ Don't do this"use client";
export function MyComponent() { // Component code}
("use server"); // This won't work!
export async function myAction() { // Server Action code}Basic Server Actions Usage
Calling Server Actions from Forms
The simplest way to use Server Actions is with HTML forms. React automatically handles the submission:
"use server";
export async function submitContact(formData: FormData) { const name = formData.get("name") as string; const email = formData.get("email") as string; const message = formData.get("message") as string;
// Send email, save to database, etc. await sendEmail({ name, email, message });
return { success: true };}import { submitContact } from '@/app/actions/contact';
export default function ContactForm() { return ( <form action={submitContact}> <div> <label htmlFor="name">Name</label> <input id="name" name="name" type="text" required /> </div>
<div> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" required /> </div>
<div> <label htmlFor="message">Message</label> <textarea id="message" name="message" required /> </div>
<button type="submit">Send Message</button> </form> );}Calling Server Actions Programmatically
You can also call Server Actions programmatically from event handlers or other functions:
'use client';
import { useState } from 'react';import { createTodo } from '@/app/actions/todo';
export function TodoForm() { const [isPending, setIsPending] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); setIsPending(true);
const formData = new FormData(event.currentTarget); const result = await createTodo(formData);
if (result.error) { alert(result.error); } else { event.currentTarget.reset(); // Show success message }
setIsPending(false); }
return ( <form onSubmit={handleSubmit}> <input name="title" required /> <button type="submit" disabled={isPending}> {isPending ? 'Creating...' : 'Create Todo'} </button> </form> );}Passing Data to Server Actions
Server Actions accept various data types:
FormData (most common for forms):
async function handleForm(formData: FormData) { const value = formData.get("fieldName");}Primitive values:
async function updateStatus(id: string, status: "active" | "inactive") { // Server Action code}
// Call from componentawait updateStatus("123", "active");Objects (serialized automatically):
async function createUser(user: { name: string; email: string }) { // Server Action code}
await createUser({ name: "John", email: "john@example.com" });⚠️ Important: Only serializable data can be passed to Server Actions. Functions, class instances, and non-serializable objects cannot be passed. Use FormData or plain objects instead.
Form Handling with Server Actions
Basic Form Submission
Server Actions integrate seamlessly with HTML forms, providing a declarative way to handle submissions:
"use server";
export async function subscribeNewsletter(formData: FormData) { const email = formData.get("email") as string;
// Validate email if (!email || !email.includes("@")) { return { error: "Invalid email address" }; }
// Add to newsletter await addToNewsletter(email);
return { success: true, message: "Successfully subscribed!" };}import { subscribeNewsletter } from '@/app/actions/newsletter';
export default function NewsletterForm() { return ( <form action={subscribeNewsletter}> <input name="email" type="email" placeholder="Enter your email" required /> <button type="submit">Subscribe</button> </form> );}Using useFormStatus Hook
React 19 provides the useFormStatus hook to access form submission state within form components:
'use client';
import { useFormStatus } from 'react-dom';import { submitForm } from '@/app/actions/form';
function SubmitButton() { const { pending } = useFormStatus();
return ( <button type="submit" disabled={pending}> {pending ? 'Submitting...' : 'Submit'} </button> );}
export function MyForm() { return ( <form action={submitForm}> <input name="name" required /> <SubmitButton /> </form> );}💡 Tip:
useFormStatusmust be used within a component that’s a descendant of a<form>element. It provides access to the nearest form’s submission state.
Using useActionState Hook
The useActionState hook (formerly useFormState) manages form state and handles Server Action results:
'use client';
import { useActionState } from 'react';import { createPost } from '@/app/actions/posts';
function CreatePostForm() { const [state, formAction, isPending] = useActionState(createPost, null);
return ( <form action={formAction}> <input name="title" required /> <textarea name="content" required />
{state?.error && ( <div className="error">{state.error}</div> )}
{state?.success && ( <div className="success">Post created successfully!</div> )}
<button type="submit" disabled={isPending}> {isPending ? 'Creating...' : 'Create Post'} </button> </form> );}"use server";
export async function createPost(prevState: any, formData: FormData) { const title = formData.get("title") as string; const content = formData.get("content") as string;
if (!title || title.trim().length < 3) { return { error: "Title must be at least 3 characters" }; }
if (!content || content.trim().length < 10) { return { error: "Content must be at least 10 characters" }; }
const post = await db.posts.create({ title, content });
return { success: true, post };}Handling File Uploads
Server Actions support file uploads through FormData:
"use server";
export async function uploadFile(formData: FormData) { const file = formData.get("file") as File;
if (!file) { return { error: "No file provided" }; }
// Check file size (e.g., max 5MB) if (file.size > 5 * 1024 * 1024) { return { error: "File size must be less than 5MB" }; }
// Save file to server const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`; const path = `./uploads/${filename}`;
await fs.writeFile(path, buffer);
return { success: true, filename };}'use client';
import { useActionState } from 'react';import { uploadFile } from '@/app/actions/upload';
export function FileUploadForm() { const [state, formAction, isPending] = useActionState(uploadFile, null);
return ( <form action={formAction} encType="multipart/form-data"> <input name="file" type="file" accept="image/*" required />
{state?.error && ( <div className="error">{state.error}</div> )}
{state?.success && ( <div className="success"> File uploaded: {state.filename} </div> )}
<button type="submit" disabled={isPending}> {isPending ? 'Uploading...' : 'Upload'} </button> </form> );}Progressive Enhancement
One of the most powerful features of Server Actions is progressive enhancement—forms work without JavaScript, then enhance with JavaScript when available.
How Progressive Enhancement Works
When JavaScript is disabled, forms submit using traditional HTML form submission. When JavaScript is enabled, React intercepts the submission and handles it via Server Actions, providing a smoother experience with loading states and instant feedback.
// This form works with or without JavaScriptexport default function ContactForm() { return ( <form action={submitContact}> <input name="name" required /> <input name="email" type="email" required /> <button type="submit">Submit</button> </form> );}Enhancing Forms with JavaScript
You can add JavaScript enhancements while maintaining the base functionality:
'use client';
import { useActionState } from 'react';import { submitContact } from '@/app/actions/contact';
export function EnhancedContactForm() { const [state, formAction, isPending] = useActionState(submitContact, null);
return ( <form action={formAction}> <input name="name" required /> <input name="email" type="email" required />
{/* Only shown when JavaScript is enabled */} {isPending && <div>Submitting...</div>} {state?.error && <div className="error">{state.error}</div>} {state?.success && <div className="success">Message sent!</div>}
<button type="submit" disabled={isPending}> {isPending ? 'Sending...' : 'Submit'} </button> </form> );}Testing Progressive Enhancement
✅ Test without JavaScript:
- Disable JavaScript in your browser
- Submit the form
- Verify it works and shows appropriate feedback
✅ Test with JavaScript:
- Enable JavaScript
- Submit the form
- Verify enhanced features (loading states, instant feedback) work
💡 Pro Tip: Progressive enhancement ensures your forms are accessible and work for all users, including those with slow connections or JavaScript disabled. This improves SEO and user experience.
Error Handling and Validation
Server-Side Validation
Always validate data on the server, even if you have client-side validation:
"use server";
import { z } from "zod";
const createUserSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Invalid email address"), age: z.number().int().min(18, "Must be at least 18 years old"),});
export async function createUser(prevState: any, formData: FormData) { try { // Parse and validate const rawData = { name: formData.get("name"), email: formData.get("email"), age: Number(formData.get("age")), };
const validated = createUserSchema.parse(rawData);
// Create user const user = await db.users.create(validated);
return { success: true, user }; } catch (error) { if (error instanceof z.ZodError) { return { error: "Validation failed", errors: error.errors.map((e) => ({ field: e.path[0], message: e.message, })), }; }
return { error: "Failed to create user" }; }}Displaying Validation Errors
Show field-specific errors in your forms:
'use client';
import { useActionState } from 'react';import { createUser } from '@/app/actions/user';
export function UserForm() { const [state, formAction, isPending] = useActionState(createUser, null);
return ( <form action={formAction}> <div> <label htmlFor="name">Name</label> <input id="name" name="name" required /> {state?.errors?.find(e => e.field === 'name') && ( <span className="error"> {state.errors.find(e => e.field === 'name')?.message} </span> )} </div>
<div> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" required /> {state?.errors?.find(e => e.field === 'email') && ( <span className="error"> {state.errors.find(e => e.field === 'email')?.message} </span> )} </div>
<div> <label htmlFor="age">Age</label> <input id="age" name="age" type="number" required /> {state?.errors?.find(e => e.field === 'age') && ( <span className="error"> {state.errors.find(e => e.field === 'age')?.message} </span> )} </div>
{state?.error && ( <div className="error">{state.error}</div> )}
<button type="submit" disabled={isPending}> {isPending ? 'Creating...' : 'Create User'} </button> </form> );}Handling Async Errors
Server Actions can throw errors that need to be caught:
"use server";
export async function processPayment(formData: FormData) { try { const amount = Number(formData.get("amount")); const cardNumber = formData.get("cardNumber") as string;
// Process payment const result = await paymentGateway.charge(amount, cardNumber);
if (!result.success) { return { error: "Payment failed", details: result.errorMessage, }; }
return { success: true, transactionId: result.id }; } catch (error) { // Log error for debugging console.error("Payment error:", error);
// Return user-friendly error return { error: "An error occurred processing your payment", }; }}Client-Side Validation (Optional Enhancement)
While server-side validation is required, client-side validation improves UX:
'use client';
import { useActionState } from 'react';import { createUser } from '@/app/actions/user';
export function UserFormWithClientValidation() { const [state, formAction, isPending] = useActionState(createUser, null); const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
function validateForm(formData: FormData) { const errors: Record<string, string> = {};
const name = formData.get('name') as string; if (!name || name.length < 2) { errors.name = 'Name must be at least 2 characters'; }
const email = formData.get('email') as string; if (!email || !email.includes('@')) { errors.email = 'Invalid email address'; }
return errors; }
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault();
const formData = new FormData(event.currentTarget); const errors = validateForm(formData);
if (Object.keys(errors).length > 0) { setClientErrors(errors); return; }
setClientErrors({}); await formAction(formData); }
return ( <form onSubmit={handleSubmit}> {/* Form fields with client error display */} </form> );}⚠️ Critical: Never rely solely on client-side validation. Always validate on the server to ensure data integrity and security.
Optimistic Updates
Optimistic updates provide instant feedback by updating the UI before the server responds, then reverting if the operation fails.
Using useOptimistic Hook
React 19’s useOptimistic hook simplifies optimistic updates:
'use client';
import { useOptimistic, useActionState } from 'react';import { updateTodo } from '@/app/actions/todos';
type Todo = { id: string; title: string; completed: boolean;};
export function TodoList({ todos }: { todos: Todo[] }) { const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (state, newTodo: Todo) => [...state, newTodo] );
async function handleToggle(todo: Todo) { // Optimistically update UI addOptimisticTodo({ ...todo, completed: !todo.completed });
// Update on server await updateTodo(todo.id, { completed: !todo.completed }); }
return ( <ul> {optimisticTodos.map(todo => ( <li key={todo.id}> <input type="checkbox" checked={todo.completed} onChange={() => handleToggle(todo)} /> {todo.title} </li> ))} </ul> );}Optimistic Updates with Error Handling
Handle failures and revert optimistic updates:
'use client';
import { useOptimistic, useState } from 'react';import { likePost } from '@/app/actions/posts';
export function Post({ post }: { post: Post }) { const [optimisticLikes, addOptimisticLike] = useOptimistic( post.likes, (state, increment: number) => state + increment );
const [error, setError] = useState<string | null>(null);
async function handleLike() { setError(null);
// Optimistically increment likes addOptimisticLike(1);
try { const result = await likePost(post.id);
if (result.error) { // Revert optimistic update addOptimisticLike(-1); setError(result.error); } } catch (error) { // Revert optimistic update addOptimisticLike(-1); setError('Failed to like post'); } }
return ( <div> <button onClick={handleLike}> Like ({optimisticLikes}) </button> {error && <div className="error">{error}</div>} </div> );}Combining useOptimistic with useActionState
Use both hooks together for comprehensive state management:
'use client';
import { useOptimistic, useActionState } from 'react';import { addComment } from '@/app/actions/comments';
export function CommentSection({ comments }: { comments: Comment[] }) { const [state, formAction, isPending] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic( comments, (state, newComment: Comment) => [...state, newComment] );
async function handleSubmit(formData: FormData) { // Create optimistic comment const optimisticComment: Comment = { id: `temp-${Date.now()}`, content: formData.get('content') as string, author: 'You', createdAt: new Date(), };
addOptimisticComment(optimisticComment);
// Submit to server const result = await formAction(formData);
if (result.error) { // Remove optimistic comment on error // (You'd need to track and remove it) } }
return ( <div> <form action={handleSubmit}> <textarea name="content" required /> <button type="submit" disabled={isPending}> {isPending ? 'Posting...' : 'Post Comment'} </button> </form>
<div> {optimisticComments.map(comment => ( <div key={comment.id}> <strong>{comment.author}</strong> <p>{comment.content}</p> </div> ))} </div> </div> );}💡 Best Practice: Use optimistic updates for operations that are likely to succeed and where instant feedback improves UX. Always handle failures gracefully and revert changes when needed.
Advanced Patterns
Server Actions with Authentication
Secure Server Actions by checking authentication:
"use server";
import { auth } from "@/lib/auth";import { redirect } from "next/navigation";
export async function updateProfile(formData: FormData) { // Check authentication const session = await auth();
if (!session) { redirect("/login"); }
const name = formData.get("name") as string;
// Update user profile await db.users.update(session.user.id, { name });
return { success: true };}Revalidating Data
Use revalidatePath and revalidateTag to refresh cached data:
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function createPost(formData: FormData) { const post = await db.posts.create({ title: formData.get("title") as string, content: formData.get("content") as string, });
// Revalidate specific path revalidatePath("/posts");
// Or revalidate by tag revalidateTag("posts");
return { success: true, post };}Streaming Responses
Server Actions can return streaming data for long-running operations:
"use server";
export async function exportData(formData: FormData) { const format = formData.get("format") as string;
// Return a stream for large exports return new ReadableStream({ async start(controller) { const data = await generateExport(format);
for (const chunk of data) { controller.enqueue(chunk); }
controller.close(); }, });}Batch Operations
Process multiple items efficiently:
"use server";
export async function deleteTodos(ids: string[]) { // Use Promise.all for parallel execution await Promise.all(ids.map((id) => db.todos.delete(id)));
revalidatePath("/todos");
return { success: true, deleted: ids.length };}Conditional Server Actions
Create flexible Server Actions that handle different scenarios:
"use server";
export async function updateUser( userId: string, formData: FormData, options?: { notify?: boolean },) { const updates: Record<string, any> = {};
if (formData.has("name")) { updates.name = formData.get("name"); }
if (formData.has("email")) { updates.email = formData.get("email"); }
const user = await db.users.update(userId, updates);
if (options?.notify) { await sendNotification(user.email, "Profile updated"); }
return { success: true, user };}Best Practices
✅ Do’s
1. Always validate on the server
"use server";
export async function createUser(formData: FormData) { // ✅ Always validate server-side const email = formData.get("email") as string; if (!email || !email.includes("@")) { return { error: "Invalid email" }; }
// Proceed with creation}2. Use TypeScript for type safety
"use server";
type CreateUserResult = | { success: true; user: User } | { success: false; error: string };
export async function createUser( formData: FormData,): Promise<CreateUserResult> { // Type-safe implementation}3. Handle errors gracefully
"use server";
export async function riskyOperation(formData: FormData) { try { // Risky operation return { success: true }; } catch (error) { // ✅ Log for debugging console.error("Operation failed:", error);
// ✅ Return user-friendly error return { error: "Operation failed. Please try again.", }; }}4. Revalidate data after mutations
"use server";
import { revalidatePath } from "next/cache";
export async function updatePost(id: string, formData: FormData) { await db.posts.update(id, { title: formData.get("title") as string, });
// ✅ Revalidate to show fresh data revalidatePath("/posts"); revalidatePath(`/posts/${id}`);
return { success: true };}5. Use progressive enhancement
// ✅ Form works without JavaScriptexport default function MyForm() { return ( <form action={submitAction}> {/* Form fields */} <button type="submit">Submit</button> </form> );}❌ Don’ts
1. Don’t trust client-side validation alone
// ❌ Never do this"use client";
export function BadForm() { function handleSubmit(event: React.FormEvent) { event.preventDefault(); const email = event.currentTarget.email.value;
// ❌ Client-side only validation if (email.includes("@")) { // Submit without server validation } }}2. Don’t expose sensitive logic
// ❌ Don't do this"use client";
export function BadComponent() { async function handleSubmit() { // ❌ Sensitive logic in client component const password = hashPassword(formData.get("password")); await fetch("/api/users", { method: "POST", body: JSON.stringify({ password }), }); }}3. Don’t forget error handling
// ❌ Don't do this"use server";
export async function badAction(formData: FormData) { // ❌ No error handling const result = await riskyOperation(formData); return result; // Might throw}4. Don’t mix Server Actions with Client Components incorrectly
// ❌ Don't do this"use client";
export function BadComponent() { // ❌ Can't define Server Action in Client Component async function serverAction() { "use server"; // This won't work! }}5. Don’t ignore loading states
// ❌ Don't do thisexport function BadForm() { return ( <form action={slowAction}> {/* ❌ No loading indicator */} <button type="submit">Submit</button> </form> );}Common Pitfalls to Avoid
Pitfall 1: Forgetting ‘use server’ Directive
❌ Wrong:
// Missing 'use server'export async function myAction(formData: FormData) { // This won't work as a Server Action}✅ Correct:
"use server";
export async function myAction(formData: FormData) { // Now it's a Server Action}Pitfall 2: Passing Non-Serializable Data
❌ Wrong:
"use server";
export async function badAction(callback: Function) { // ❌ Functions can't be serialized}✅ Correct:
"use server";
export async function goodAction(data: { name: string; age: number }) { // ✅ Plain objects are serializable}Pitfall 3: Not Handling Errors
❌ Wrong:
"use server";
export async function riskyAction(formData: FormData) { // ❌ No error handling const result = await db.operation(formData); return result;}✅ Correct:
"use server";
export async function safeAction(formData: FormData) { try { const result = await db.operation(formData); return { success: true, result }; } catch (error) { return { success: false, error: "Operation failed" }; }}Pitfall 4: Not Revalidating After Mutations
❌ Wrong:
"use server";
export async function createItem(formData: FormData) { await db.items.create(formData); // ❌ UI won't update with new data return { success: true };}✅ Correct:
"use server";
import { revalidatePath } from "next/cache";
export async function createItem(formData: FormData) { await db.items.create(formData); revalidatePath("/items"); // ✅ UI updates return { success: true };}Pitfall 5: Incorrect useFormStatus Usage
❌ Wrong:
function MyComponent() { const { pending } = useFormStatus(); // ❌ Not inside form return <button>Submit</button>;}✅ Correct:
function SubmitButton() { const { pending } = useFormStatus(); // ✅ Inside form return <button disabled={pending}>Submit</button>;}
function MyForm() { return ( <form action={myAction}> <SubmitButton /> </form> );}Conclusion
React 19 Server Actions represent a fundamental shift in how we handle data mutations and form submissions in React applications. By enabling direct server-side function calls from components, Server Actions eliminate the need for separate API routes, reduce boilerplate code, and provide built-in progressive enhancement.
Throughout this guide, we’ve explored the core concepts of Server Actions, from basic form handling to advanced patterns like optimistic updates and error handling. We’ve seen how Server Actions integrate seamlessly with React 19’s new hooks like useActionState and useOptimistic, providing powerful tools for building modern, performant applications.
Key takeaways:
- Server Actions simplify form handling by eliminating the need for API routes and manual fetch calls
- Progressive enhancement ensures your forms work for all users, with or without JavaScript
- Type safety is built-in when using TypeScript, catching errors at compile time
- Error handling and validation are essential for robust applications
- Optimistic updates provide instant feedback and improve user experience
As you build with Server Actions, remember to always validate on the server, handle errors gracefully, and revalidate data after mutations. Follow the best practices outlined in this guide, and avoid common pitfalls like forgetting the 'use server' directive or passing non-serializable data.
Server Actions work beautifully with React 19’s other features, including Server Components (as covered in our React Server Components guide) and the new hooks introduced in React 19 (explored in our React 19 features guide). Together, these features provide a powerful foundation for building modern, full-stack React applications.
Start implementing Server Actions in your projects, experiment with the patterns we’ve covered, and leverage the power of React 19’s server-side capabilities to build better, faster, and more maintainable applications.