3 min read
React recipe
For Next.js specifically see the Next.js recipe. For Remix see Remix. This page covers plain React (Vite or CRA-style apps).
Uncontrolled form (the simple one)
Let the DOM hold the values. Read them with FormData on submit. Less state, less re-rendering, fewer bugs.
import { useState } from 'react';
export function Contact() {
const [status, setStatus] = useState<'idle' | 'sending' | 'ok' | 'error'>('idle');
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('sending');
const fd = new FormData(e.currentTarget);
const r = await fetch(import.meta.env.VITE_FORMSPRING_URL, {
method: 'POST',
body: fd,
});
setStatus(r.ok ? 'ok' : 'error');
if (r.ok) {
e.currentTarget.reset();
}
}
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
{status === 'ok' && <p>Thanks.</p>}
{status === 'error' && <p className="err">Something went wrong.</p>}
</form>
);
}
Controlled form (when you need it)
Use controlled inputs only when you need to react to keystrokes — character counters, validation as you type, dependent fields.
import { useState } from 'react';
export function Contact() {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [sending, setSending] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSending(true);
await fetch(import.meta.env.VITE_FORMSPRING_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, message }),
});
setSending(false);
setEmail('');
setMessage('');
}
return (
<form onSubmit={onSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required />
<p>{message.length} / 500</p>
<button disabled={sending}>Send</button>
</form>
);
}
With useActionState (React 19+)
If you're on React 19, the new useActionState cuts boilerplate further. You write an async action, React tracks pending state.
import { useActionState } from 'react';
async function submitContact(_prev: any, formData: FormData) {
const r = await fetch(import.meta.env.VITE_FORMSPRING_URL, {
method: 'POST',
body: formData,
});
if (!r.ok) {
return { error: 'Submission failed.' };
}
return { ok: true };
}
export function Contact() {
const [state, action, pending] = useActionState(submitContact, null);
return (
<form action={action}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
{state?.ok && <p>Thanks.</p>}
{state?.error && <p className="err">{state.error}</p>}
</form>
);
}
Reusable hook
// src/hooks/useFormspring.ts
import { useState } from 'react';
export function useFormspring(endpoint: string) {
const [status, setStatus] = useState<'idle' | 'sending' | 'ok' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
async function submit(payload: FormData | Record<string, unknown>) {
setStatus('sending');
setError(null);
try {
const init: RequestInit = { method: 'POST' };
if (payload instanceof FormData) {
init.body = payload;
} else {
init.headers = { 'Content-Type': 'application/json' };
init.body = JSON.stringify(payload);
}
const r = await fetch(endpoint, init);
if (!r.ok) {
throw new Error(`HTTP ${r.status}`);
}
setStatus('ok');
} catch (e: any) {
setStatus('error');
setError(e.message);
}
}
return { status, error, submit };
}