WebToolsPlanet
Developerguide·7 min read

How to Submit a React Form to an API

Submit a React form to an API with fetch — controlled inputs, a submit handler with loading/error states, and honeypot protection. Free generator included.

Published 7/21/2026
Updated 7/23/2026
React form submitting data to an API
Submit a React form to an API

A React form isn't a plain HTML form with a useState sprinkled on top — it's a different pattern entirely. The inputs are controlled by state, the submit event is intercepted before the browser ever navigates anywhere, and the "did it work" answer comes back from a fetch call instead of a page reload. Get any one of those three pieces wrong and the form looks fine until someone actually clicks submit.

Quick Answer

Submit a React form to an API by keeping every input's value in useState, calling event.preventDefault() in the submit handler so the browser doesn't navigate, and sending the state object with fetch — tracking 'idle' | 'submitting' | 'success' | 'error' status so the UI can show a loading state and a real result. Form Studio's React export generates this exact pattern from a visual builder.


Use the Free Tool

Form Studio generates a complete controlled React component — state-backed inputs, a submit handler wired to whichever endpoint you configure, loading/success/error UI, and an optional honeypot guard — from fields you add visually. Open the contact template pre-loaded with React output, set your API URL in the Delivery tab, and copy the generated component.


Step 1: Make Every Input Controlled

A controlled input's value lives in React state, not in the DOM. This is what makes the current form values available as a plain object the moment you need to submit them, instead of having to query the DOM for each field:

import { useState } from 'react'; function ContactForm() { const [values, setValues] = useState({ name: '', email: '', message: '' }); return ( <form> <input name="name" value={values.name} onChange={(e) => setValues((v) => ({ ...v, name: e.target.value }))} /> {/* ... */} </form> ); }

The onChange handler updates one key of the values object on every keystroke, using the functional setValues((v) => ...) form so each update is based on the latest state rather than a stale closure — important if multiple fields could update in quick succession.


Step 2: Prevent the Default Submission and Call the API

A native <form> submission navigates the browser to a new URL by default — exactly what you don't want when you're handling the request yourself with fetch:

const [status, setStatus] = useState('idle'); async function handleSubmit(event) { event.preventDefault(); setStatus('submitting'); try { const response = await fetch('https://api.example.com/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values), }); if (!response.ok) throw new Error('Request failed'); setStatus('success'); } catch (error) { setStatus('error'); } }
<form onSubmit={handleSubmit}> {/* fields */} <button type="submit" disabled={status === 'submitting'}> {status === 'submitting' ? 'Sending…' : 'Submit'} </button> {status === 'success' && <p role="status">Thanks — your message was sent.</p>} {status === 'error' && <p role="alert">Something went wrong. Please try again.</p>} </form>

event.preventDefault() is not optional — without it, the browser starts a full-page navigation to the form's action URL (or the current page, if none is set) before your fetch call has any chance to run.


Step 3: Guard Against Duplicate Submissions

disabled={status === 'submitting'} on the button prevents a second click from firing a second request while the first is still in flight — a common source of duplicate database rows or duplicate notification emails on slower connections, where a user assumes a click that produced no visible change simply "didn't register."


Handling Radio Groups and Checkboxes

Uncontrolled radio and checkbox groups are the most common gap in hand-written React forms — leaving them without checked/onChange means the selection never reaches values, and a real user's answer to that question silently disappears from every submission.

Radio group (single value):

<input type="radio" name="plan" value="Pro" checked={values.plan === 'Pro'} onChange={(e) => setValues((v) => ({ ...v, plan: e.target.value }))} />

Checkbox group (array of values):

const [interests, setInterests] = useState([]); <input type="checkbox" value="News" checked={interests.includes('News')} onChange={(e) => { setInterests((prev) => e.target.checked ? [...prev, 'News'] : prev.filter((item) => item !== 'News') ); }} />

Form Studio generates both of these patterns automatically for any radio or checkbox field you add — no manual wiring required.


Adding a Honeypot Guard

If the API is public, a bot will find it. Add a hidden field to the same values object and check it first in the submit handler, before any real network request fires:

const [values, setValues] = useState({ name: '', email: '', message: '', hpWebsite: '' }); async function handleSubmit(event) { event.preventDefault(); if (values.hpWebsite) return; // honeypot triggered — silently ignore likely bot submissions // ...rest of submit logic }
<div style={{ position: 'absolute', left: '-9999px' }} aria-hidden="true"> <label htmlFor="hp-website">Leave this field blank</label> <input id="hp-website" name="hp_website" tabIndex={-1} autoComplete="off" value={values.hpWebsite} onChange={(e) => setValues((v) => ({ ...v, hpWebsite: e.target.value }))} /> </div>

Step-by-Step Workflow

Step 1: Build the fields in Form Studio or start from the pattern above, keeping every input controlled.

Step 2: Write a handleSubmit that calls event.preventDefault() first, then fetchs your API with the values object as the JSON body.

Step 3: Track a status state and use it to disable the submit button and render loading/success/error UI.

Step 4: Add radio/checkbox handling for any grouped fields, and a honeypot guard if the endpoint is public.

Step 5: Test the actual network request in your browser's DevTools — confirm the request body matches what your API expects, and that a failed request (try an invalid URL temporarily) correctly shows the error state instead of hanging silently.


Common Mistakes

Forgetting event.preventDefault(). The page navigates away before fetch ever runs — the most common "my submit button does nothing" bug.

Leaving radio or checkbox groups uncontrolled. No checked/onChange means the user's selection never makes it into values, and you won't notice until you inspect the actual payload sent to your API.

No loading state on the button. Without disabled={status === 'submitting'}, an impatient double-click sends two requests.

Catching the error but not showing anything. A catch block that only does console.error leaves the user staring at a form that appears to have done nothing.

Building the request body from FormData in a controlled form. If every input is already controlled, you have the values in state — reading FormData from the DOM on top of that is redundant and can drift out of sync with what's actually rendered.


Best Practices

  • Reset values to their initial state after a successful submission so a user can't accidentally resubmit the same data.

  • Re-validate on the server. Client-side required/type="email" checks are a UX nicety, not a security boundary — anyone can send a request directly to your API bypassing the form entirely.

  • Use AbortController if a user might navigate away mid-request, to cancel the in-flight fetch and avoid a "set state on unmounted component" warning.

  • Show field-specific errors when your API returns them, not just a single generic failure message.

  • Keep the honeypot field's tabIndex={-1} and autoComplete="off" so it never interferes with real keyboard users tabbing through the form.


The Developer Tools category has the rest of the frontend utilities.


FAQ

Do I need a library like Formik or React Hook Form?

No — for a form with a handful of fields, plain useState is simpler and has no dependency to maintain. Form libraries earn their keep on large forms with complex cross-field validation, dynamic field arrays, or performance concerns from frequent re-renders on every keystroke.

Why does my fetch call succeed but my API says the body is empty?

Check that you're setting 'Content-Type': 'application/json' in the request headers and calling JSON.stringify(values) on the body — a common mistake is sending the raw object without stringifying it, which fetch will otherwise send as "[object Object]".

How do I show a specific error message from my API instead of a generic one?

Parse the response body even on a failed request (const data = await response.json()) and store its message in an error state, rather than only checking response.ok and discarding the body.

Should the fetch call include credentials like cookies?

Only if your API needs them — add credentials: 'include' to the fetch options for cross-origin requests that require an authenticated session. Most public contact-form endpoints don't need this.

Can I use this same pattern with Next.js?

Yes — Next.js client components (files marked 'use client') use the identical pattern. The only difference is the 'use client' directive at the top of the file, which Form Studio's Next.js export adds automatically.


Build Your React Form Now

Form Studio's React export generates a controlled component with a working submit handler, loading/error states, and an optional honeypot — free, and it runs entirely in your browser.

Khushbu

Khushbu

Full-Stack Developer & Founder

I build tools I wish existed — fast, free, and private. Every tool runs in your browser because I believe your data should stay yours.