React Hook Form
Send a React Hook Form contact form to your inbox with validation, submit state and the honeypot wired in.
React Hook Form handles the parts of a contact form that get tedious by hand: validation rules, per-field error messages and a reliable "submitting" flag. SilentForms handles delivery. Put them together and handleSubmit only has to post one JSON object.
What you'll build#
A contact form with React Hook Form that:
- validates name, email and message in the browser with messages next to each field,
- disables the button while the request is in flight,
- shows server errors (such as a rate limit) in a form-level message via
setError, - clears itself after a successful submission,
- sends the honeypot field like any other registered input.
You need a React project (Vite, Next.js or similar) and an access key from the access key page. Install the library:
npm install react-hook-form1. Add the key#
With Vite, put the key in .env.local:
VITE_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HEREand read it as import.meta.env.VITE_SILENTFORMS_KEY. In Next.js use NEXT_PUBLIC_SILENTFORMS_KEY and process.env.NEXT_PUBLIC_SILENTFORMS_KEY instead (and add 'use client' to the component). The key is safe in browser code: it only routes submissions to your inbox.
2. The form#
Create src/ContactForm.jsx:
import { useState } from 'react';
import { useForm } from 'react-hook-form';
const ACCESS_KEY = import.meta.env.VITE_SILENTFORMS_KEY;
export default function ContactForm() {
const [sent, setSent] = useState(false);
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm({ defaultValues: { name: '', email: '', message: '', botcheck: false } });
async function onSubmit(values) {
setSent(false);
const response = await fetch('https://silentforms.com/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ ...values, accessKey: ACCESS_KEY, subject: 'New message from the website' }),
}).catch(() => null);
const result = response ? await response.json().catch(() => ({})) : {};
if (!response?.ok) {
setError('root.server', { message: result.message || 'Could not reach the server. Please try again.' });
return;
}
reset();
setSent(true);
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="cf-name">Name</label>
<input
id="cf-name"
autoComplete="name"
aria-invalid={errors.name ? 'true' : 'false'}
{...register('name', { required: 'Please tell us your name.' })}
/>
{errors.name && <p role="alert">{errors.name.message}</p>}
<label htmlFor="cf-email">Email</label>
<input
id="cf-email"
type="email"
autoComplete="email"
aria-invalid={errors.email ? 'true' : 'false'}
{...register('email', {
required: 'Please enter your email.',
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'That email address does not look right.' },
})}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
<label htmlFor="cf-message">Message</label>
<textarea
id="cf-message"
rows={5}
aria-invalid={errors.message ? 'true' : 'false'}
{...register('message', {
required: 'Please write a message.',
minLength: { value: 10, message: 'A little more detail, please (10 characters or more).' },
})}
/>
{errors.message && <p role="alert">{errors.message.message}</p>}
<input type="checkbox" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" {...register('botcheck')} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending…' : 'Send message'}
</button>
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
<p role="status">{sent ? 'Thanks! Your message has been sent.' : ''}</p>
</form>
);
}How the pieces fit#
isSubmitting follows your promise. React Hook Form sets it to true while onSubmit runs, so onSubmit must be async and must await the request. If you fire the fetch without awaiting it, the button re-enables immediately.
Server errors go into root. Errors set on root.server are not tied to a field and do not block the next submit: React Hook Form clears them when the visitor submits again. The message comes from the endpoint and is written for visitors, for example "Too many requests. Please try again in a minute." See Troubleshooting for the full list.
reset() only on success. Calling reset() restores defaultValues and clears the form state, which is why the success message lives in its own sent state rather than in isSubmitSuccessful. Because reset() is skipped when setError runs, the visitor keeps their text after a failed attempt.
noValidate hands validation to React Hook Form. Without it, the browser's built-in bubbles appear first and your messages never show. Keep type="email" anyway: it gives mobile visitors the right keyboard. The endpoint still checks the email address, so a value that slips past the pattern comes back as "Email address is invalid." in the root error.
The honeypot#
Registering botcheck as a checkbox means it is always in values, and an unchecked box arrives as "botcheck": false in the JSON. SilentForms treats false the same as an empty value, so real visitors are never flagged. A bot that ticks the hidden box sends true; it still gets a success response, but nothing is emailed and the submission lands in the dashboard's spam view. More in Honeypot.
With a schema resolver#
If you already validate with Zod, Yup or Valibot through @hookform/resolvers, nothing above changes except the register options, which move into the schema. Leave botcheck out of strict schemas or mark it optional, or the resolver will strip or reject it.
Testing#
- Submit the empty form: each field shows its message and nothing is sent (check the Network tab).
- Fill it in and submit: the button reads "Sending…", the fields clear and the email arrives.
- Submit more than ten times in a minute: the rate-limit message appears under the button and the fields keep their text.
Next steps#
- The same form without a library: React.
- Set the subject or sender name per form: Email subject, Sender name.
- Add a CAPTCHA widget: hCaptcha.