Loading content…
Loading content…
Master React 19 forms hooks: useActionState, useFormStatus, and useOptimistic with Zod schema validation and security in Server Actions
useActionState (previously useFormState) and useFormStatus.<form> is currently submitting. Important: It only works when placed in a component nested inside the <form> element.// app/actions.ts
"use server";
export interface ActionState {
success: boolean;
message: string;
errors?: Record<string, string>;
}
export async function submitContactForm(prevState: ActionState, formData: FormData): Promise<ActionState> {
const email = formData.get("email") as string;
if (!email || !email.includes("@")) {
return {
success: false,
message: "Validation failed",
errors: { email: "Please enter a valid email address." }
};
}
// Process contact submission (e.g. database query, email sending)
return {
success: true,
message: "Thank you! Your message was submitted successfully."
};
}
// app/ContactForm.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { submitContactForm, ActionState } from "./actions";
const initialState: ActionState = {
success: false,
message: ""
};
// Custom Submit Button consuming useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-indigo-600 text-white rounded disabled:bg-gray-400"
>
{pending ? "Submitting..." : "Send Message"}
</button>
);
}
export default function ContactForm() {
const [state, formAction] = useActionState(submitContactForm, initialState);
return (
<form action={formAction} className="flex flex-col gap-4 max-w-sm p-6 bg-white border rounded-xl">
<div>
<label className="block text-sm font-semibold mb-1">Email Address</label>
<input name="email" type="text" className="w-full border rounded p-2" />
{state.errors?.email && (
<p className="text-red-500 text-xs mt-1">{state.errors.email}</p>
)}
</div>
<SubmitButton />
{state.message && (
<p className={state.success ? "text-green-600 text-sm" : "text-red-600 text-sm"}>
{state.message}
</p>
)}
</form>
);
}
useOptimistic hook lets you render the expected final state instantly, then reverts to the server response if the action fails.// app/actions.ts
"use server";
export async function createTodoAction(text: string) {
// Simulate slow network request (2 seconds)
await new Promise(resolve => setTimeout(resolve, 2000));
// Real DB insert:
return { id: Math.random().toString(), text, pending: false };
}
// app/TodoList.tsx
"use client";
import { useOptimistic, useRef, startTransition } from "react";
import { createTodoAction } from "./actions";
interface Todo {
id: string;
text: string;
pending: boolean;
}
export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const formRef = useRef<HTMLFormElement>(null);
// 1. Set up useOptimistic hook
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newTodoText: string) => [
...state,
{ id: Date.now().toString(), text: newTodoText, pending: true }
]
);
const handleFormAction = async (formData: FormData) => {
const text = formData.get("text") as string;
if (!text.trim()) return;
formRef.current?.reset();
// 2. Trigger UI update instantly
startTransition(() => {
addOptimisticTodo(text);
});
// 3. Perform actual server mutation
await createTodoAction(text);
};
return (
<div className="p-6 max-w-sm bg-white border rounded-xl">
<form ref={formRef} action={handleFormAction} className="flex gap-2 mb-4">
<input name="text" placeholder="New task..." className="border rounded p-2 flex-1" />
<button type="submit" className="bg-indigo-600 text-white px-3 rounded">+</button>
</form>
<ul className="flex flex-col gap-2">
{optimisticTodos.map(todo => (
<li key={todo.id} className="flex justify-between p-2 bg-slate-50 rounded border text-sm">
<span>{todo.text}</span>
{todo.pending && <span className="text-gray-400 text-xs italic">Syncing...</span>}
</li>
))}
</ul>
</div>
);
}
import { z } from "zod";
const RegistrationSchema = z.object({
username: z.string().min(3, "Username must be at least 3 characters"),
email: z.string().email("Invalid email format"),
password: z.string().min(8, "Password must be at least 8 characters")
});
export async function registerUser(prevState: any, formData: FormData) {
// Convert FormData to object
const data = Object.fromEntries(formData.entries());
// Safe parse values
const result = RegistrationSchema.safeParse(data);
if (!result.success) {
// Return structured validation errors
const fieldErrors = result.error.flatten().fieldErrors;
return {
success: false,
message: "Form validation failed",
errors: Object.fromEntries(
Object.entries(fieldErrors).map(([key, val]) => [key, val?.[0] || ""])
)
};
}
// Save validated user
const { username, email } = result.data;
await db.user.create({ username, email });
return { success: true, message: "User registered successfully!" };
}
Common Pitfall
// app/actions.ts
"use server";
import { getCurrentUser } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limiter";
export async function deletePost(postId: string) {
// 1. Authenticate user
const user = await getCurrentUser();
if (!user) {
throw new Error("Unauthorized access.");
}
// 2. Rate limit request
const isAllowed = await rateLimit(user.id, 5); // Max 5 deletions per minute
if (!isAllowed) {
throw new Error("Too many requests. Please try again later.");
}
// 3. Authorize action
const post = await db.post.findById(postId);
if (post.authorId !== user.id && user.role !== "ADMIN") {
throw new Error("Forbidden: You do not own this post.");
}
// 4. Perform mutation
await db.post.delete(postId);
return { success: true };
}
Senior Developer Wisdom
Pro Tip
<form action={action}> instead of custom click handles. This guarantees usability on low-power devices.Server Actions Checklist
useActionState, useFormStatus) to simplify form feedback.Marking it complete updates your roadmap progress percentage.