React
A React contact form that posts straight to your endpoint from the browser, with submitting, success and error states. No server, no API route.
Before you start
You need a form endpoint from the dashboard (see the Quickstart) and a React app, built with Vite or anything else that renders components in the browser. The component posts straight from the browser, so there is no server code to write.
- 1
Add the component
Save this as
ContactForm.tsx. It is a complete, typed form with its own submitting, success and error states.ContactForm.tsximport { useState, type FormEvent } from 'react'; type Status = | { state: 'idle' | 'submitting' | 'success' } | { state: 'error'; message: string }; export function ContactForm() { const [status, setStatus] = useState<Status>({ state: 'idle' }); async function handleSubmit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); const form = event.currentTarget; setStatus({ state: 'submitting' }); try { const response = await fetch('https://mtform.co/f/your-form-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(form))), }); const result = await response.json(); if (!response.ok) { throw new Error(result.error?.message ?? 'Unable to send your message.'); } form.reset(); setStatus({ state: 'success' }); } catch (error) { // Entered values stay put; we only reset on success. const message = error instanceof Error ? error.message : 'Unable to send your message.'; setStatus({ state: 'error', message }); } } return ( <form onSubmit={handleSubmit}> <input name="name" placeholder="Name" required /> <input type="email" name="email" placeholder="Email" required /> <textarea name="message" placeholder="Message" required /> {/* Spam trap: hidden from people, tempting to bots. */} <input type="text" name="_mtf_honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }} /> <button type="submit" disabled={status.state === 'submitting'}> {status.state === 'submitting' ? 'Sending…' : 'Send'} </button> {status.state === 'success' && <p role="status">Thanks. We got your message.</p>} {status.state === 'error' && <p role="alert">{status.message}</p>} </form> ); }Then render it wherever the form belongs.
ContactPage.tsximport { ContactForm } from './ContactForm'; export default function ContactPage() { return ( <main> <h1>Contact us</h1> <ContactForm /> </main> ); }The endpoint URL sits in client code on purpose. It can only accept submissions, never read them, so there is nothing to hide in an environment variable.
- 2
Read how it handles errors
The handler is short, but each line is there for a reason. If you copy it into your own form, keep these behaviours:
- JSON in, JSON out. The body is sent with
Content-Type: application/json, so the endpoint replies with JSON rather than a redirect. - Any 2xx is success. A new submission is a
201. Checkresponse.ok, not an exact status. - Reset only on success. On failure the visitor's text stays in the fields, and
error.messagefrom the response is shown. Those messages are written to be shown to visitors. - Disable the button while sending. It prevents double submissions and re-enables once the request settles either way.
Plain JavaScript? Save it as.jsxand delete theStatustype, the<Status>onuseStateand the parameter type onhandleSubmit. Nothing else changes. - JSON in, JSON out. The body is sent with
- 3
Test it
Submit the form in your browser. The button reads Sending…, then the thank-you line appears and the fields clear. Open Submissions in the dashboard to see it.
To see the error path, pause the form in its settings and submit again. The endpoint answers
form_disabledand its message appears under the button, with your text still in the fields.
The spam trap in JSX
The hidden _mtf_honeypot input is how the form catches most bots. Two details differ from plain HTML: React spells the attributes tabIndex and autoComplete, and style takes an object.
Keep it positioned off-screen as the component does. Do not swap it for display: none, type="hidden" or a conditional render: bots skip fields they can tell are hidden, and a field that is not in the DOM is never sent. If you use a form library, make sure it submits this field with the rest and does not mark it required.