Use when adding new error messages to React, or seeing "unknown error code" warnings.
npx skills add soilmass/vibe-coding-plugin --skill "react-forms"
Install specific skill from multi-skill repository
# Description
>
# SKILL.md
name: react-forms
description: >
React 19 forms — useActionState, useFormStatus, useOptimistic, Zod validation, Server Action integration, progressive enhancement
allowed-tools: Read, Grep, Glob
React Forms
Purpose
React 19 form patterns with Server Action integration. Covers useActionState, useFormStatus,
useOptimistic, and Zod validation. The ONE skill for form handling.
When to Use
- Building forms that submit to Server Actions
- Adding optimistic updates to form submissions
- Implementing form validation with Zod
- Showing pending/loading states during submission
When NOT to Use
- Server Action implementation →
react-server-actions - API endpoint form processing →
api-routes - Component styling →
shadcn
Pattern
useActionState (replaces deprecated useFormState)
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createTodo } from "@/actions/createTodo";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create"}
</button>
);
}
export function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, {});
return (
<form action={formAction}>
<input name="title" required />
{state.error?.title && <p className="text-red-500">{state.error.title}</p>}
<SubmitButton />
</form>
);
}
useOptimistic
"use client";
import { useOptimistic } from "react";
export function TodoList({
todos,
addTodo,
}: {
todos: Todo[];
addTodo: (formData: FormData) => Promise<void>;
}) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo: string) => [
...state,
{ id: "temp", title: newTodo, completed: false },
]
);
return (
<>
<ul>
{optimisticTodos.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
<form
action={async (formData) => {
addOptimistic(formData.get("title") as string);
await addTodo(formData);
}}
>
<input name="title" />
<button type="submit">Add</button>
</form>
</>
);
}
Async field validation (debounced server check)
"use client";
import { useTransition, useState } from "react";
import { checkUsernameAvailable } from "@/actions/checkUsername";
export function UsernameField() {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
if (value.length < 3) return;
startTransition(async () => {
const available = await checkUsernameAvailable(value);
setError(available ? null : "Username already taken");
});
}
return (
<div>
<input name="username" onChange={handleChange} />
{isPending && <span className="text-muted-foreground text-sm">Checking...</span>}
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
);
}
Zod validation → per-field error display
// Server Action: Zod validation returns fieldErrors
"use server";
import { z } from "zod";
const CreateTodoSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title too long"),
description: z.string().max(500, "Description too long").optional(),
});
type FormState = {
success?: boolean;
error?: Record<string, string[]>;
};
export async function createTodo(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const parsed = CreateTodoSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors };
await db.todo.create({ data: parsed.data });
revalidatePath("/todos");
return { success: true };
}
// Client form: per-field errors with aria attributes
"use client";
import { useActionState } from "react";
import { createTodo } from "@/actions/createTodo";
export function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, {});
return (
<form action={formAction}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
aria-invalid={!!state.error?.title}
aria-describedby={state.error?.title ? "title-error" : undefined}
/>
{state.error?.title && (
<p id="title-error" className="text-red-500 text-sm">
{state.error.title[0]}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
</form>
);
}
Anti-pattern
// WRONG: using useFormState (deprecated in React 19)
import { useFormState } from "react-dom"; // DEPRECATED!
function MyForm() {
const [state, formAction] = useFormState(myAction, {});
// Use useActionState from "react" instead
}
useFormState is deprecated. Use useActionState from "react" (not "react-dom").
Common Mistakes
- Using
useFormStateinstead ofuseActionState— deprecated - Importing
useActionStatefrom"react-dom"— it's in"react" - Putting
useFormStatusin the same component as<form>— must be a child - Not providing initial state to
useActionState— second argument required - Missing Zod validation in the Server Action — see
react-server-actionsfor validation patterns
Checklist
- [ ]
useActionStatefrom"react"(notuseFormState) - [ ]
useFormStatusin a child component of<form>, not sibling - [ ] Initial state provided as second argument to
useActionState - [ ] Server Action validates with Zod (client validation is UX only)
- [ ] Optimistic updates via
useOptimisticwhere appropriate
Composes With
react-server-actions— forms submit to server actionsshadcn— shadcn Form component provides structureerror-handling— form errors shown inline, not in error boundarieslogging— track form submissions and validation failures
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.