On this page
React developers often over-engineer forms: a useState for every field, an onChange handler for every input, and a validation library for a three-field contact form. Modern React — and plain HTML — can do much of this for you.
This guide shows three approaches, from simplest to most flexible, all submitting to a form backend so you don't need your own server.
Approach 1: Uncontrolled form + FormData (recommended for most forms)
Let the DOM hold the values. Read them all at once on submit with FormData.
import { useState } from 'react';
export default function ContactForm() {
const [status, setStatus] = useState('idle'); // idle | sending | sent | error
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
setStatus('sending');
try {
const response = await fetch('https://api.formclick.io/submit', {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.message);
form.reset();
setStatus('sent');
} catch {
setStatus('error');
}
}
return (
<form onSubmit={handleSubmit}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input type="checkbox" name="botcheck" style={{ display: 'none' }} tabIndex={-1} />
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Send'}
</button>
<p role="status" aria-live="polite">
{status === 'sent' && 'Thanks! We will reply soon.'}
{status === 'error' && 'Something went wrong. Please try again.'}
</p>
</form>
);
}
Why this is a good default:
- No state per field — fewer re-renders and far less code.
- Native validation still works —
requiredandtype="email"run beforeonSubmitfires. - Files just work — FormData includes file inputs automatically.
- Remember React's attribute names:
htmlFor,autoComplete,tabIndex.
Approach 2: Controlled inputs (when the UI depends on values)
Use controlled inputs when you need to react to values as they change — live character counters, dependent fields, instant formatting.
import { useState } from 'react';
const MAX = 500;
export default function FeedbackForm() {
const [message, setMessage] = useState('');
const [rating, setRating] = useState('');
return (
<form action="https://api.formclick.io/submit" method="POST">
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<label htmlFor="rating">Rating</label>
<select id="rating" name="rating" value={rating}
onChange={(e) => setRating(e.target.value)} required>
<option value="">Choose…</option>
<option value="5">Excellent</option>
<option value="3">OK</option>
<option value="1">Poor</option>
</select>
{rating === '1' && (
<>
<label htmlFor="issue">What went wrong?</label>
<input id="issue" name="issue" required />
</>
)}
<label htmlFor="message">Comments</label>
<textarea id="message" name="message" maxLength={MAX}
value={message} onChange={(e) => setMessage(e.target.value)} />
<small>{MAX - message.length} characters left</small>
<button type="submit">Send feedback</button>
</form>
);
}
Notice this form uses a plain action — it submits normally, with React only managing the dynamic parts.
Approach 3: React 19 form actions
React 19 lets you pass a function to a form's action prop. Combined with useActionState, it handles pending state for you:
import { useActionState } from 'react';
async function sendMessage(previousState, formData) {
const response = await fetch('https://api.formclick.io/submit', {
method: 'POST',
headers: { Accept: 'application/json' },
body: formData,
});
const result = await response.json();
return result.success
? { ok: true, message: 'Thanks! Message sent.' }
: { ok: false, message: result.message || 'Please try again.' };
}
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(sendMessage, null);
return (
<form action={formAction}>
<input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />
<input name="email" type="email" required aria-label="Email" />
<textarea name="message" required aria-label="Message" />
<button disabled={isPending}>{isPending ? 'Sending…' : 'Send'}</button>
{state && <p role="status">{state.message}</p>}
</form>
);
}
React resets uncontrolled fields automatically after a successful action.
Keep the access key in config
Store the key in an environment variable so different environments can use different forms:
// Vite: import.meta.env.VITE_FORMCLICK_KEY
// Next.js: process.env.NEXT_PUBLIC_FORMCLICK_KEY
<input type="hidden" name="access_key" value={import.meta.env.VITE_FORMCLICK_KEY} />
It's safe for this key to reach the browser: a Formclick access key can only submit to its own form, not read submissions or settings.
Validation: when to add a library
- Contact, feedback, sign-up forms — native HTML validation is enough.
- Complex forms with cross-field rules, arrays of fields or multi-step flows — consider React Hook Form with a schema library such as Zod.
Accessibility checklist for React forms
- Every input has a
<label htmlFor>or anaria-label. - Status messages live in an element with
role="status". - Focus moves to the first invalid field or the success message after submit.
- Buttons are real
<button>elements with clear text.
Not using React? The same pattern in vanilla JS is in How to Submit HTML Forms Using the Fetch API. Ready-made designs are in our HTML form templates.



