React 19: Server Actions and useActionState
Learn to build forms with server actions using useActionState, the React 19 replacement for useFormState.
Topics Covered:
Prerequisites:
- React 19: Optimistic UI Updates with useOptimistic
Overview
useActionState (formerly useFormState) is React 19's hook for managing form state with server actions. It simplifies form handling in Next.js and React Server Components, providing built-in pending state and error handling. This tutorial covers server actions, form state management, error handling, and integration with Next.js App Router.
Lesson 1: Understanding Server Actions
Server actions are async functions that run on the server, called directly from client components. Benefits: • No API routes needed • Type-safe end-to-end • Progressive enhancement • Built-in security Server Action Requirements: • Marked with 'use server' • Async function • Can accept FormData or serializable data • Returns serializable response Next.js Integration: • Can be in separate files or inline • Automatically handled by Next.js • Works with App Router
// Server action (app/actions.ts)
'use server';
export async function submitForm(formData: FormData) {
const email = formData.get('email') as string;
// Validate
if (!email || !email.includes('@')) {
return { error: 'Invalid email' };
}
// Database operations, API calls, etc.
await saveToDatabase(email);
return { success: true, message: 'Saved!' };
}
// Client component usage
'use client';
import { submitForm } from '@/app/actions';
export function Form() {
async function handleSubmit(formData: FormData) {
const result = await submitForm(formData);
console.log(result);
}
return (
<form action={handleSubmit}>
<input name="email" type="email" />
<button type="submit">Submit</button>
</form>
);
}
// Server actions can also accept objects
'use server';
export async function createUser(data: {
name: string;
email: string;
}) {
// Server-side validation
if (!data.name || !data.email) {
return { error: 'Missing fields' };
}
const user = await db.user.create({ data });
return { user };
}Server actions run on the server, eliminating the need for API routes. They're type-safe, secure, and work seamlessly with forms.
Lesson 2: useActionState Hook Basics
useActionState manages form state with server actions, providing pending state and previous state. Hook Signature: ```typescript const [state, formAction, pending] = useActionState( action, initialState ); ``` Parameters: • action: Server action function • initialState: Initial state value Returns: • state: Current state (result from last action) • formAction: Action wrapper for forms • pending: Boolean indicating if action is in progress Key Features: • Automatic pending state • Previous state tracking • Form integration • Error handling
import { useActionState } from 'react';
import { submitForm } from './actions';
interface FormState {
message?: string;
error?: string;
}
const initialState: FormState = {};
export function ContactForm() {
const [state, formAction, pending] = useActionState(submitForm, initialState);
return (
<form action={formAction}>
<input
name="email"
type="email"
required
disabled={pending}
/>
{state.error && (
<p className="error">{state.error}</p>
)}
{state.message && (
<p className="success">{state.message}</p>
)}
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
// Server action
'use server';
export async function submitForm(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const email = formData.get('email') as string;
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
if (!email || !email.includes('@')) {
return {
error: 'Please enter a valid email address',
};
}
// Save to database
await saveEmail(email);
return {
message: 'Thank you for subscribing!',
};
}useActionState wraps a server action, providing state management and pending status. The action receives previous state and FormData, returning new state.
Lesson 3: Advanced Form Patterns
Learn advanced patterns for complex forms with useActionState. Common Patterns: • Multi-step forms • File uploads • Dynamic form fields • Real-time validation • Field-level errors Best Practices: • Validate on server • Show loading states • Handle errors gracefully • Provide feedback • Optimistic updates where appropriate
// Multi-step form
function MultiStepForm() {
const [step, setStep] = useState(1);
const [state, formAction, pending] = useActionState(submitStep, {});
return (
<form action={formAction}>
{step === 1 && <Step1 />}
{step === 2 && <Step2 />}
{step === 3 && <Step3 />}
<div>
{step > 1 && (
<button type="button" onClick={() => setStep(s => s - 1)}>
Back
</button>
)}
<button type="submit" disabled={pending}>
{step < 3 ? 'Next' : 'Submit'}
</button>
</div>
</form>
);
}
// File upload form
'use server';
export async function uploadFile(
prevState: any,
formData: FormData
) {
const file = formData.get('file') as File;
if (!file) {
return { error: 'No file selected' };
}
if (file.size > 5 * 1024 * 1024) {
return { error: 'File too large' };
}
// Upload to storage
const url = await uploadToS3(file);
return { success: true, url };
}
// Field-level errors
interface FormState {
errors?: {
email?: string;
password?: string;
name?: string;
};
message?: string;
}
'use server';
export async function registerUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const errors: FormState['errors'] = {};
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const name = formData.get('name') as string;
if (!email || !email.includes('@')) {
errors.email = 'Invalid email';
}
if (!password || password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!name || name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (Object.keys(errors).length > 0) {
return { errors };
}
// Create user
await createUser({ email, password, name });
return { message: 'Account created successfully!' };
}
// Usage with field errors
function RegistrationForm() {
const [state, formAction, pending] = useActionState(registerUser, {});
return (
<form action={formAction}>
<div>
<input name="email" type="email" />
{state.errors?.email && (
<span className="error">{state.errors.email}</span>
)}
</div>
<div>
<input name="password" type="password" />
{state.errors?.password && (
<span className="error">{state.errors.password}</span>
)}
</div>
<button type="submit" disabled={pending}>
Register
</button>
</form>
);
}useActionState handles complex forms with multi-step flows, file uploads, and field-level validation. Server-side validation ensures data integrity.
Lesson 4: Combining with useOptimistic
Combine useActionState with useOptimistic for the best user experience. Combination Benefits: • Instant UI feedback • Server-side validation • Automatic rollback on errors • Best of both worlds Pattern: • useActionState for form submission • useOptimistic for immediate feedback • Handle errors gracefully
// Combining useActionState and useOptimistic
function CommentForm({ postId }: { postId: number }) {
const [comments, setComments] = useState<Comment[]>([]);
const [state, formAction, pending] = useActionState(
addComment,
{ comments }
);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(current, newComment: Comment) => [newComment, ...current]
);
// Update comments when action succeeds
useEffect(() => {
if (state.comments) {
setComments(state.comments);
}
}, [state.comments]);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
// Optimistic update
const tempComment: Comment = {
id: `temp-${Date.now()}`,
text,
author: currentUser,
createdAt: new Date(),
};
addOptimistic(tempComment);
// Submit with action
await formAction(formData);
// State updates automatically via useEffect
}
return (
<div>
{optimisticComments.map(comment => (
<Comment key={comment.id} comment={comment} />
))}
<form action={handleSubmit}>
<textarea name="text" disabled={pending} />
<button type="submit" disabled={pending}>
{pending ? 'Posting...' : 'Post Comment'}
</button>
</form>
</div>
);
}
// Server action
'use server';
export async function addComment(
prevState: { comments: Comment[] },
formData: FormData
) {
const text = formData.get('text') as string;
if (!text || text.trim().length === 0) {
return {
...prevState,
error: 'Comment cannot be empty',
};
}
const comment = await createComment({
text,
postId: formData.get('postId'),
});
return {
comments: [comment, ...prevState.comments],
};
}Combining useActionState with useOptimistic provides instant feedback while maintaining server-side validation. Users see immediate updates, and errors are handled gracefully.
Lesson 5: Migration from useFormState
useActionState replaces useFormState in React 19. Here's how to migrate. Key Changes: • Renamed: useFormState → useActionState • Same API • Better TypeScript support • Improved performance Migration Steps: 1. Update imports 2. Rename hook usage 3. Test thoroughly 4. Update documentation
// Before (React 18 - useFormState)
import { useFormState } from 'react-dom';
function Form() {
const [state, formAction] = useFormState(submitForm, initialState);
// ...
}
// After (React 19 - useActionState)
import { useActionState } from 'react';
function Form() {
const [state, formAction, pending] = useActionState(
submitForm,
initialState
);
// pending is now available!
// ...
}
// Migration checklist:
// ✅ Rename import
// ✅ Rename hook
// ✅ Add pending state usage (optional but recommended)
// ✅ Update TypeScript types if needed
// ✅ Test all forms
// Example migration
// OLD
const [state, formAction] = useFormState(submitForm, {});
// NEW
const [state, formAction, pending] = useActionState(submitForm, {});
// You can now use pending
<button disabled={pending || state.loading}>
{pending ? 'Submitting...' : 'Submit'}
</button>Migration is straightforward - just rename the hook. The API is identical, and you gain access to the pending state for better UX.
Conclusion
useActionState simplifies form handling with server actions. It provides built-in state management, pending status, and seamless integration with Next.js. Combine with useOptimistic for the best user experience. Always validate on the server for security.