
React Syntax Forms
- 11 installs
- 6 repo stars
- Updated July 8, 2026
- openaec-foundation/react-claude-skill-package
Helps with frontend development tasks.
About
react-syntax-forms is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted development.
- react-syntax-forms
- Frontend Development
- AI-coding skill
React Syntax Forms by the numbers
- 11 all-time installs (skills.sh)
- Ranked #1,658 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/react-claude-skill-package --skill react-syntax-formsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 6 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/react-claude-skill-package ↗ |
What it does
Helps with frontend development tasks.
Files
react-syntax-forms
Quick Reference
Form Approach Overview
| Approach | React Version | State Owner | Best For |
|---|---|---|---|
| Controlled inputs | 18 + 19 | React (value + onChange) | Real-time validation, conditional fields, formatted input |
| Uncontrolled inputs | 18 + 19 | DOM (defaultValue + ref) | File inputs, third-party integrations, simple forms |
| Form actions | 19+ | FormData (<form action={fn}>) | Async submissions, progressive enhancement, Server Functions |
Input Type Quick Reference
| Input Type | Controlled Prop | Event Value Access | TypeScript Type |
|---|---|---|---|
text | value | e.target.value | string |
number | value | Number(e.target.value) | number |
checkbox | checked | e.target.checked | boolean |
radio | checked | e.target.value | string |
select | value | e.target.value | string |
textarea | value | e.target.value | string |
file | ALWAYS uncontrolled | e.target.files | `FileList \ |
Critical Warnings
NEVER mix value and defaultValue on the same element. Choose controlled OR uncontrolled.
NEVER set value on a file input. File inputs are ALWAYS uncontrolled because their value is read-only for security reasons.
NEVER call useFormStatus in the same component that renders <form>. It MUST be called from a child component rendered inside the <form>.
NEVER use the deprecated useFormState from react-dom. ALWAYS use useActionState from react instead.
ALWAYS call e.preventDefault() in onSubmit handlers (React 18 pattern). Form actions (React 19) handle this automatically.
---
Decision Tree: Which Form Approach?
Need real-time validation or formatted input?
YES --> Controlled inputs (value + onChange)
NO -->
Using React 19 with async submission?
YES --> Form actions (<form action={fn}>)
NO -->
Need file upload or third-party widget?
YES --> Uncontrolled inputs (defaultValue + ref)
NO -->
Simple form with few fields?
YES --> Uncontrolled inputs
NO --> Controlled inputsWhen to Use Each
| Use Case | Approach | Why |
|---|---|---|
| Search-as-you-type | Controlled | Need value on every keystroke |
| Credit card formatting | Controlled | Must transform input in real time |
| File upload | Uncontrolled | File inputs cannot be controlled |
| Server action form | Form actions (React 19) | Built-in pending state, progressive enhancement |
| Simple contact form | Uncontrolled or form actions | No per-keystroke logic needed |
| Multi-step wizard | Controlled | Must track and validate across steps |
---
Controlled Inputs
React state is the single source of truth. Every keystroke triggers a re-render.
function ControlledForm() {
const [name, setName] = useState<string>('');
const [age, setAge] = useState<number>(0);
const [agreed, setAgreed] = useState<boolean>(false);
const [role, setRole] = useState<string>('developer');
return (
<form onSubmit={(e) => { e.preventDefault(); /* submit */ }}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input type="number" value={age} onChange={(e) => setAge(Number(e.target.value))} />
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
<button type="submit">Submit</button>
</form>
);
}Multi-Field State: Object vs Individual
Individual `useState` calls — ALWAYS use for forms with 1-4 unrelated fields:
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');Single state object — ALWAYS use for forms with 5+ fields or related fields:
interface FormData {
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
}
const [form, setForm] = useState<FormData>({
firstName: '', lastName: '', email: '', phone: '', address: ''
});
const updateField = (field: keyof FormData, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};---
Uncontrolled Inputs
The DOM manages the value. Use defaultValue for initial values and refs for reading.
function UncontrolledForm() {
const nameRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const name = nameRef.current?.value;
// submit name
}
return (
<form onSubmit={handleSubmit}>
<input defaultValue="initial text" ref={nameRef} />
<textarea defaultValue="initial bio" />
<select defaultValue="developer">
<option value="developer">Developer</option>
<option value="designer">Designer</option>
</select>
<button type="submit">Submit</button>
</form>
);
}File Inputs
File inputs are ALWAYS uncontrolled. NEVER attempt to set value on a file input.
function FileUpload() {
const fileRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const files: FileList | null = fileRef.current?.files ?? null;
if (files && files.length > 0) {
const file: File = files[0];
// process file
}
}
return (
<form onSubmit={handleSubmit}>
<input type="file" ref={fileRef} accept=".pdf,.jpg,.png" />
<input type="file" ref={fileRef} multiple /> {/* multiple files */}
<button type="submit">Upload</button>
</form>
);
}---
Form Submission with FormData
Works in both React 18 and 19. Uses the browser's native FormData API.
function FormDataExample() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const email = formData.get('email') as string;
// submit { name, email }
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Submit</button>
</form>
);
}---
React 19 Form Actions
React 19+
Pass an async function directly to <form action>. React handles submission inside a Transition.
function SearchForm() {
async function handleSearch(formData: FormData) {
const query = formData.get('query') as string;
await searchAPI(query);
}
return (
<form action={handleSearch}>
<input name="query" />
<button type="submit">Search</button>
</form>
);
}Key behaviors:
- HTTP method is ALWAYS POST when using a function action
- Uncontrolled fields automatically reset after successful submission
- Forms work without JavaScript when using Server Functions (progressive enhancement)
Multiple Submit Actions
React 19+
function Editor() {
async function publish(formData: FormData) { /* publish */ }
async function saveDraft(formData: FormData) { /* save */ }
return (
<form action={publish}>
<textarea name="content" />
<button type="submit">Publish</button>
<button formAction={saveDraft}>Save Draft</button>
</form>
);
}---
useFormStatus
React 19+
Reads the parent form's submission state. Import from react-dom.
import { useFormStatus } from 'react-dom';
// MUST be a separate component rendered INSIDE <form>
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
function MyForm() {
return (
<form action={submitAction}>
<input name="email" />
<SubmitButton /> {/* Reads parent form state */}
</form>
);
}| Property | Type | Description |
|---|---|---|
pending | boolean | true while parent form is submitting |
data | `FormData \ | null` |
method | `'get' \ | 'post'` |
action | `function \ | null` |
---
useActionState
React 19+
Manages action state, error handling, and pending state. Import from react.
import { useActionState } from 'react';
interface FormState {
error: string | null;
success: boolean;
}
async function submitForm(prev: FormState, formData: FormData): Promise<FormState> {
const email = formData.get('email') as string;
if (!email.includes('@')) return { error: 'Invalid email', success: false };
await saveEmail(email);
return { error: null, success: true };
}
function EmailForm() {
const [state, formAction, isPending] = useActionState(submitForm, {
error: null, success: false,
});
return (
<form action={formAction}>
<input name="email" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.success && <p className="success">Saved!</p>}
</form>
);
}---
useOptimistic
React 19+
Provides instant UI feedback during async operations. Automatically reverts on failure.
import { useOptimistic } from 'react';
interface Todo {
id: string;
text: string;
pending?: boolean;
}
function TodoList({ todos, addTodo }: { todos: Todo[]; addTodo: (text: string) => Promise<void> }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current: Todo[], newText: string) => [
...current,
{ id: 'temp', text: newText, pending: true },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic(text);
await addTodo(text);
}
return (
<div>
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" />
<button type="submit">Add</button>
</form>
</div>
);
}---
Validation Patterns
HTML5 Built-in Validation
ALWAYS use native validation attributes as the first line of defense:
<input type="email" required />
<input type="text" minLength={3} maxLength={50} required />
<input type="number" min={0} max={100} step={1} />
<input type="text" pattern="[A-Za-z]{3,}" title="At least 3 letters" />Custom Validation with Controlled Inputs
function ValidatedForm() {
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
function validate(): boolean {
const newErrors: Record<string, string> = {};
if (!email.includes('@')) newErrors.email = 'Invalid email address';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (validate()) { /* submit */ }
}
return (
<form onSubmit={handleSubmit} noValidate>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <span id="email-error" role="alert">{errors.email}</span>}
<button type="submit">Submit</button>
</form>
);
}---
Reference Links
- references/examples.md -- Complete form patterns with TypeScript
- references/patterns.md -- Form architecture and composition patterns
- references/anti-patterns.md -- Common form mistakes and fixes
Official Sources
- https://react.dev/reference/react-dom/components/form
- https://react.dev/reference/react-dom/components/input
- https://react.dev/reference/react-dom/components/select
- https://react.dev/reference/react-dom/components/textarea
- https://react.dev/reference/react/useActionState
- https://react.dev/reference/react/useOptimistic
- https://react.dev/reference/react-dom/hooks/useFormStatus
react-syntax-forms — Anti-Patterns
Common form mistakes and their fixes. Every anti-pattern includes WHY it is wrong and the correct alternative.
---
Anti-Pattern 1: Mixing value and defaultValue
// WRONG: React ignores defaultValue when value is present
<input value={name} defaultValue="fallback" onChange={handleChange} />
// CORRECT: Choose one approach
// Controlled:
<input value={name} onChange={handleChange} />
// Uncontrolled:
<input defaultValue="fallback" />WHY: React treats an input as controlled when value is set. defaultValue is silently ignored. This creates confusion about which value the input actually displays.
---
Anti-Pattern 2: Controlled Input Without onChange
// WRONG: Input is frozen — user cannot type
<input value={name} />
// CORRECT: Always pair value with onChange
<input value={name} onChange={(e) => setName(e.target.value)} />
// Or use readOnly if intentional:
<input value={name} readOnly />WHY: Setting value without onChange makes the input read-only. React logs a warning: "You provided a value prop to a form field without an onChange handler."
---
Anti-Pattern 3: Setting value on File Inputs
// WRONG: Throws an error — file inputs are read-only
<input type="file" value={fileName} onChange={handleChange} />
// CORRECT: File inputs are ALWAYS uncontrolled
<input type="file" ref={fileRef} onChange={(e) => handleFiles(e.target.files)} />WHY: Browsers prohibit programmatic setting of file input values for security reasons. ALWAYS use refs or FormData to access file input values.
---
Anti-Pattern 4: useFormStatus in the Form Component
// WRONG: pending is ALWAYS false — reads status of PARENT form, not THIS form
function MyForm() {
const { pending } = useFormStatus();
return (
<form action={submitAction}>
<input name="email" />
<button disabled={pending}>Submit</button>
</form>
);
}
// CORRECT: Extract button to a child component
function SubmitButton() {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
function MyForm() {
return (
<form action={submitAction}>
<input name="email" />
<SubmitButton />
</form>
);
}WHY: useFormStatus reads the status of the nearest PARENT <form>. When called in the same component that renders the <form>, there is no parent form to read from.
---
Anti-Pattern 5: Using Deprecated useFormState
// WRONG: useFormState is deprecated in React 19
import { useFormState } from 'react-dom';
const [state, formAction] = useFormState(fn, initialState);
// CORRECT: Use useActionState from 'react'
import { useActionState } from 'react';
const [state, formAction, isPending] = useActionState(fn, initialState);WHY: useFormState was renamed to useActionState in React 19 and moved from react-dom to react. The new API also returns isPending as the third element, eliminating the need for a separate useFormStatus call in many cases.
---
Anti-Pattern 6: Missing preventDefault on Form Submit
// WRONG: Page reloads on submit (default browser behavior)
function MyForm() {
function handleSubmit() {
console.log('submitted');
}
return <form onSubmit={handleSubmit}><button type="submit">Go</button></form>;
}
// CORRECT: Prevent default browser submission
function MyForm() {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
console.log('submitted');
}
return <form onSubmit={handleSubmit}><button type="submit">Go</button></form>;
}WHY: Without preventDefault(), the browser performs a full page navigation. This resets all React state and triggers a page reload. Note: React 19 form actions (<form action={fn}>) handle this automatically.
---
Anti-Pattern 7: Syncing Form State with useEffect
// WRONG: Extra render cycle, stale UI for one frame
function ProfileForm({ userId }: { userId: string }) {
const [name, setName] = useState('');
useEffect(() => {
fetchUser(userId).then((user) => setName(user.name));
}, [userId]);
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
// BETTER: Use key to force remount when userId changes
function ProfilePage({ userId }: { userId: string }) {
return <ProfileForm key={userId} userId={userId} />;
}
function ProfileForm({ userId }: { userId: string }) {
const [name, setName] = useState(''); // Fresh state on each userId
// Fetch and set name...
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}WHY: Using useEffect to reset form state on prop changes causes a flash of stale content. The key prop forces React to destroy and recreate the component with fresh state.
---
Anti-Pattern 8: Index as Key for Dynamic Form Fields
// WRONG: Input state gets mixed up when fields are reordered or removed
{fields.map((field, index) => (
<input key={index} value={field.value} onChange={...} />
))}
// CORRECT: Use stable unique IDs
{fields.map((field) => (
<input key={field.id} value={field.value} onChange={...} />
))}WHY: When you remove field at index 1, all subsequent indices shift. React reuses DOM nodes by key — so field 2's DOM node now holds field 3's state. User input appears in the wrong fields.
---
Anti-Pattern 9: Mutating State Directly in Form Handlers
// WRONG: Mutating the state object directly
function handleChange(field: string, value: string) {
form[field] = value; // Direct mutation — React does NOT detect this
setForm(form); // Same reference — no re-render
}
// CORRECT: Create a new object
function handleChange(field: string, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
}WHY: React uses reference equality (Object.is) to determine if state changed. Mutating the existing object and passing it to setState produces the same reference, so React skips the re-render.
---
Anti-Pattern 10: Not Disabling Inputs During Submission
// WRONG: User can submit multiple times or change values mid-submission
function MyForm() {
const [state, formAction, isPending] = useActionState(submitFn, initialState);
return (
<form action={formAction}>
<input name="email" />
<button type="submit">Submit</button>
</form>
);
}
// CORRECT: Disable all interactive elements while pending
function MyForm() {
const [state, formAction, isPending] = useActionState(submitFn, initialState);
return (
<form action={formAction}>
<input name="email" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}WHY: Without disabling, users can submit the form multiple times (causing duplicate requests) or change input values while the previous submission is still in progress.
---
Anti-Pattern 11: Using textarea Children Instead of value
// WRONG: React ignores children of <textarea>
<textarea>Initial content</textarea>
// CORRECT (controlled):
<textarea value={content} onChange={(e) => setContent(e.target.value)} />
// CORRECT (uncontrolled):
<textarea defaultValue="Initial content" />WHY: In HTML, <textarea> uses children for its content. In React, ALWAYS use value (controlled) or defaultValue (uncontrolled). React ignores the children of <textarea>.
---
Anti-Pattern 12: Reading Form Values from State After Uncontrolled Input
// WRONG: State is never updated — input is uncontrolled
function MyForm() {
const [name, setName] = useState('');
return (
<form onSubmit={() => console.log(name)}>
<input defaultValue="" /> {/* No onChange — name stays '' */}
<button type="submit">Submit</button>
</form>
);
}
// CORRECT Option A: Make it controlled
<input value={name} onChange={(e) => setName(e.target.value)} />
// CORRECT Option B: Use ref for uncontrolled
const nameRef = useRef<HTMLInputElement>(null);
<input defaultValue="" ref={nameRef} />
// Read: nameRef.current?.value
// CORRECT Option C: Use FormData
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
console.log(fd.get('name'));
}WHY: Uncontrolled inputs do not update React state. The DOM holds the value, but your useState variable stays at its initial value. Use refs or FormData to read uncontrolled input values.
---
Summary: Decision Rules
| Situation | ALWAYS | NEVER |
|---|---|---|
| Controlled input | Pair value with onChange | Set value without onChange |
| File input | Use uncontrolled with ref | Set value on file input |
| Form submission (React 18) | Call e.preventDefault() | Let the browser navigate |
useFormStatus | Call from a child inside <form> | Call in the component rendering <form> |
| Dynamic field keys | Use stable unique IDs | Use array index as key |
| State updates | Create new objects/arrays | Mutate existing state |
| During submission | Disable inputs and buttons | Allow interaction while pending |
<textarea> value | Use value or defaultValue prop | Use children content |
react-syntax-forms — Examples
Complete form patterns with TypeScript. All examples verified against react.dev official documentation.
---
Controlled Text Input with TypeScript
import { useState, type ChangeEvent, type FormEvent } from 'react';
function TextInputForm() {
const [name, setName] = useState<string>('');
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setName(e.target.value);
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
console.log('Submitted:', name);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" type="text" value={name} onChange={handleChange} />
<button type="submit">Submit</button>
</form>
);
}---
Controlled Number Input
import { useState } from 'react';
function AgeInput() {
const [age, setAge] = useState<number>(0);
return (
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
min={0}
max={150}
/>
);
}ALWAYS use Number(e.target.value) to convert the string value. e.target.value is ALWAYS a string, even for number inputs.
---
Controlled Checkbox
import { useState } from 'react';
function CheckboxExample() {
const [agreed, setAgreed] = useState<boolean>(false);
return (
<label>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
I agree to the terms
</label>
);
}ALWAYS use checked (not value) for checkboxes. Use e.target.checked (not e.target.value).
---
Controlled Radio Group
import { useState } from 'react';
type Size = 'small' | 'medium' | 'large';
function RadioGroupExample() {
const [size, setSize] = useState<Size>('medium');
return (
<fieldset>
<legend>Size</legend>
{(['small', 'medium', 'large'] as const).map((option) => (
<label key={option}>
<input
type="radio"
name="size"
value={option}
checked={size === option}
onChange={(e) => setSize(e.target.value as Size)}
/>
{option}
</label>
))}
</fieldset>
);
}ALWAYS give all radios in a group the same name attribute. Use checked to control selection.
---
Controlled Select
import { useState } from 'react';
function SelectExample() {
const [country, setCountry] = useState<string>('nl');
return (
<select value={country} onChange={(e) => setCountry(e.target.value)}>
<option value="">-- Select --</option>
<option value="nl">Netherlands</option>
<option value="de">Germany</option>
<option value="be">Belgium</option>
</select>
);
}Multiple Select
import { useState, type ChangeEvent } from 'react';
function MultiSelectExample() {
const [selected, setSelected] = useState<string[]>([]);
function handleChange(e: ChangeEvent<HTMLSelectElement>) {
const options = Array.from(e.target.selectedOptions, (opt) => opt.value);
setSelected(options);
}
return (
<select multiple value={selected} onChange={handleChange}>
<option value="react">React</option>
<option value="vue">Vue</option>
<option value="angular">Angular</option>
</select>
);
}---
Controlled Textarea
import { useState } from 'react';
function TextareaExample() {
const [bio, setBio] = useState<string>('');
return (
<div>
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
maxLength={500}
/>
<p>{bio.length}/500</p>
</div>
);
}NEVER use <textarea>content</textarea> in React. ALWAYS use the value prop (controlled) or defaultValue (uncontrolled).
---
Multi-Field Form with Single State Object
import { useState, type ChangeEvent, type FormEvent } from 'react';
interface ContactForm {
firstName: string;
lastName: string;
email: string;
phone: string;
message: string;
}
const initialState: ContactForm = {
firstName: '', lastName: '', email: '', phone: '', message: '',
};
function ContactFormExample() {
const [form, setForm] = useState<ContactForm>(initialState);
function handleChange(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
const { name, value } = e.target;
setForm((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
console.log('Submitted:', form);
setForm(initialState); // reset
}
return (
<form onSubmit={handleSubmit}>
<input name="firstName" value={form.firstName} onChange={handleChange} />
<input name="lastName" value={form.lastName} onChange={handleChange} />
<input name="email" type="email" value={form.email} onChange={handleChange} />
<input name="phone" type="tel" value={form.phone} onChange={handleChange} />
<textarea name="message" value={form.message} onChange={handleChange} />
<button type="submit">Send</button>
</form>
);
}---
File Input with Multiple Files
import { useRef, type FormEvent } from 'react';
function MultiFileUpload() {
const fileRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const files = fileRef.current?.files;
if (!files || files.length === 0) return;
const fileArray: File[] = Array.from(files);
fileArray.forEach((file) => {
console.log(`${file.name} — ${file.size} bytes — ${file.type}`);
});
// Upload via FormData
const formData = new FormData();
fileArray.forEach((file) => formData.append('files', file));
// await fetch('/upload', { method: 'POST', body: formData });
}
return (
<form onSubmit={handleSubmit}>
<input type="file" ref={fileRef} multiple accept="image/*,.pdf" />
<button type="submit">Upload</button>
</form>
);
}---
Uncontrolled Form with useRef
import { useRef, type FormEvent } from 'react';
function UncontrolledLoginForm() {
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const email = emailRef.current?.value ?? '';
const password = passwordRef.current?.value ?? '';
console.log('Login:', { email, password });
}
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} type="email" defaultValue="" placeholder="Email" />
<input ref={passwordRef} type="password" defaultValue="" placeholder="Password" />
<button type="submit">Log In</button>
</form>
);
}---
React 19: Form Action with useActionState
React 19+
import { useActionState } from 'react';
interface SignupState {
error: string | null;
success: boolean;
}
async function signupAction(prev: SignupState, formData: FormData): Promise<SignupState> {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (password.length < 8) {
return { error: 'Password must be at least 8 characters', success: false };
}
try {
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' },
});
return { error: null, success: true };
} catch {
return { error: 'Signup failed. Try again.', success: false };
}
}
function SignupForm() {
const [state, formAction, isPending] = useActionState(signupAction, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="email" type="email" required disabled={isPending} />
<input name="password" type="password" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Signing up...' : 'Sign Up'}
</button>
{state.error && <p role="alert">{state.error}</p>}
{state.success && <p>Account created!</p>}
</form>
);
}---
React 19: useFormStatus in Child Component
React 19+
import { useFormStatus } from 'react-dom';
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Processing...' : label}
</button>
);
}
function FormWithStatus() {
async function handleAction(formData: FormData) {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log('Submitted:', formData.get('name'));
}
return (
<form action={handleAction}>
<input name="name" required />
<SubmitButton label="Save" />
</form>
);
}---
React 19: useOptimistic with Form
React 19+
import { useOptimistic, startTransition } from 'react';
interface Message {
id: string;
text: string;
pending?: boolean;
}
function MessageList({
messages,
sendMessage,
}: {
messages: Message[];
sendMessage: (text: string) => Promise<void>;
}) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(current: Message[], newText: string): Message[] => [
...current,
{ id: 'optimistic-' + Date.now(), text: newText, pending: true },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic(text);
await sendMessage(text);
}
return (
<div>
<ul>
{optimisticMessages.map((msg) => (
<li key={msg.id} style={{ opacity: msg.pending ? 0.5 : 1 }}>
{msg.text}
{msg.pending && ' (sending...)'}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="text" required />
<button type="submit">Send</button>
</form>
</div>
);
}---
Validation with Error Display and Accessibility
import { useState, type FormEvent } from 'react';
interface FormErrors {
email?: string;
password?: string;
}
function ValidatedLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<FormErrors>({});
function validate(): FormErrors {
const newErrors: FormErrors = {};
if (!email) newErrors.email = 'Email is required';
else if (!email.includes('@')) newErrors.email = 'Invalid email format';
if (!password) newErrors.password = 'Password is required';
else if (password.length < 8) newErrors.password = 'Minimum 8 characters';
return newErrors;
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
const validationErrors = validate();
setErrors(validationErrors);
if (Object.keys(validationErrors).length === 0) {
// submit form
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<span id="password-error" role="alert">{errors.password}</span>
)}
</div>
<button type="submit">Log In</button>
</form>
);
}---
FormData API Usage (React 18 + 19)
function FormDataExample() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
// Get individual values
const name = fd.get('name') as string;
const email = fd.get('email') as string;
// Get all values for multi-select or repeated fields
const tags = fd.getAll('tags') as string[];
// Convert to plain object
const data = Object.fromEntries(fd.entries());
console.log({ name, email, tags, data });
}
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<input name="email" type="email" />
<select name="tags" multiple>
<option value="react">React</option>
<option value="typescript">TypeScript</option>
</select>
<button type="submit">Submit</button>
</form>
);
}react-syntax-forms — Patterns
Form architecture and composition patterns for React 18 and 19.
---
Pattern: Reusable Form Field Component
Extract form fields into reusable components with consistent error display and accessibility.
interface FieldProps {
label: string;
name: string;
type?: string;
value: string;
error?: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
}
function FormField({ label, name, type = 'text', value, error, onChange, required }: FieldProps) {
const errorId = `${name}-error`;
return (
<div>
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
required={required}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && <span id={errorId} role="alert">{error}</span>}
</div>
);
}---
Pattern: Form State with useReducer
For forms with many fields and complex validation, useReducer provides cleaner state management than multiple useState calls.
import { useReducer, type FormEvent } from 'react';
interface FormState {
values: { email: string; password: string; confirmPassword: string };
errors: Record<string, string>;
isSubmitting: boolean;
}
type FormAction =
| { type: 'SET_FIELD'; field: string; value: string }
| { type: 'SET_ERRORS'; errors: Record<string, string> }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_END' }
| { type: 'RESET' };
const initialState: FormState = {
values: { email: '', password: '', confirmPassword: '' },
errors: {},
isSubmitting: false,
};
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return { ...state, values: { ...state.values, [action.field]: action.value } };
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'SUBMIT_START':
return { ...state, isSubmitting: true, errors: {} };
case 'SUBMIT_END':
return { ...state, isSubmitting: false };
case 'RESET':
return initialState;
default:
return state;
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const errors = validate(state.values);
if (Object.keys(errors).length > 0) {
dispatch({ type: 'SET_ERRORS', errors });
return;
}
dispatch({ type: 'SUBMIT_START' });
await submitRegistration(state.values);
dispatch({ type: 'SUBMIT_END' });
}
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.email}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })}
disabled={state.isSubmitting}
/>
{state.errors.email && <span role="alert">{state.errors.email}</span>}
<button type="submit" disabled={state.isSubmitting}>Register</button>
</form>
);
}---
Pattern: Controlled Form Reset
React 18: Manual Reset
function ResettableForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
function handleReset() {
setName('');
setEmail('');
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Submit</button>
<button type="button" onClick={handleReset}>Reset</button>
</form>
);
}React 18: Key-Based Reset
Force full re-mount by changing the key prop. ALWAYS prefer this for complex forms.
function FormContainer() {
const [formKey, setFormKey] = useState(0);
function handleSubmitSuccess() {
setFormKey((k) => k + 1); // forces re-mount with fresh state
}
return <MyForm key={formKey} onSuccess={handleSubmitSuccess} />;
}React 19: Automatic Reset with Form Actions
React 19+
Uncontrolled form fields automatically reset after a successful form action. No manual reset needed.
function AutoResetForm() {
async function handleAction(formData: FormData) {
await submitToServer(formData);
// Form fields reset automatically on success
}
return (
<form action={handleAction}>
<input name="name" />
<button type="submit">Submit</button>
</form>
);
}---
Pattern: Debounced Search Input
For search-as-you-type where you need to delay API calls.
import { useState, useEffect, useRef } from 'react';
function DebouncedSearch({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (query.length > 0) onSearch(query);
}, 300);
return () => clearTimeout(timerRef.current);
}, [query, onSearch]);
return (
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}---
Pattern: Dynamic Form Fields
Add and remove fields dynamically. ALWAYS use stable IDs for keys, NEVER array indices.
import { useState } from 'react';
interface FieldEntry {
id: string;
value: string;
}
let nextId = 0;
function DynamicForm() {
const [fields, setFields] = useState<FieldEntry[]>([
{ id: String(nextId++), value: '' },
]);
function addField() {
setFields((prev) => [...prev, { id: String(nextId++), value: '' }]);
}
function removeField(id: string) {
setFields((prev) => prev.filter((f) => f.id !== id));
}
function updateField(id: string, value: string) {
setFields((prev) => prev.map((f) => (f.id === id ? { ...f, value } : f)));
}
return (
<form>
{fields.map((field) => (
<div key={field.id}>
<input
value={field.value}
onChange={(e) => updateField(field.id, e.target.value)}
/>
<button type="button" onClick={() => removeField(field.id)}>Remove</button>
</div>
))}
<button type="button" onClick={addField}>Add Field</button>
</form>
);
}---
Pattern: Composing useFormStatus with useActionState
React 19+
Combine both hooks for a complete form solution with error state and pending UI.
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
// Child component — reads parent form pending state
function FormContent({ error }: { error: string | null }) {
const { pending } = useFormStatus();
return (
<>
<input name="email" type="email" required disabled={pending} />
<input name="password" type="password" required disabled={pending} />
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Logging in...' : 'Log In'}
</button>
</>
);
}
// Parent component — manages form action and state
function LoginForm() {
const [state, formAction] = useActionState(
async (prev: { error: string | null }, formData: FormData) => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const result = await loginAPI(email, password);
if (!result.ok) return { error: result.message };
return { error: null };
},
{ error: null }
);
return (
<form action={formAction}>
<FormContent error={state.error} />
</form>
);
}---
Pattern: Progressive Enhancement with Server Functions
React 19+
Forms that work before JavaScript loads when using a framework with Server Functions.
// actions.ts
"use server";
export async function createTodo(prevState: { error: string | null }, formData: FormData) {
const title = formData.get('title') as string;
if (!title.trim()) return { error: 'Title is required' };
await db.todos.create({ title });
return { error: null };
}// TodoForm.tsx
"use client";
import { useActionState } from 'react';
import { createTodo } from './actions';
function TodoForm() {
const [state, formAction, isPending] = useActionState(createTodo, { error: null });
return (
<form action={formAction}>
<input name="title" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add Todo'}
</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}This form submits as a standard HTML form if JavaScript has not loaded yet. When JavaScript loads, React enhances it with pending state and client-side error display.
---
Pattern: Accessible Form Structure
ALWAYS follow these accessibility rules for forms:
1. ALWAYS associate labels with inputs via htmlFor/id 2. ALWAYS use aria-invalid on inputs with validation errors 3. ALWAYS use aria-describedby to link error messages to inputs 4. ALWAYS use role="alert" on error messages for screen reader announcements 5. ALWAYS use <fieldset> and <legend> for grouped controls (radio groups, checkbox groups) 6. NEVER rely solely on color to indicate errors
<form>
<fieldset>
<legend>Contact Information</legend>
<div>
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
name="fullName"
aria-required="true"
aria-invalid={!!errors.fullName}
aria-describedby={errors.fullName ? 'fullName-error' : 'fullName-hint'}
/>
<span id="fullName-hint">Enter your first and last name</span>
{errors.fullName && (
<span id="fullName-error" role="alert">{errors.fullName}</span>
)}
</div>
</fieldset>
<button type="submit">Submit</button>
</form>