Skip to content

Join the Seedly owners community →

React

Building a Form

Put your React skills together by building a complete form with controlled inputs, validation, submission, and error handling

Written by 14 min read1 activity
Sprout, your presenter

Sprout presents

Five steps from controlled inputs to validation, submission and success messages. Every skill from this module, assembled into one form. Delightful.Five steps from controlled inputs to validation, submission and success messages. Every skill from this module, assembled into one form. Delightful.

Sprout fills in a paper form on a clipboard with green ticks beside each line, a postbox nearby
A good form brings state, events and validation together

Forms are EVERYWHERE in web apps. Sign up pages, search bars, checkout flows, settings panels... and every contact form a business owner has ever relied on for leads. Building a good one in React pulls together everything you've learned so far. State keeps track of what people type, events respond to what they do, conditional rendering shows the errors, and an async event handler takes care of sending it all off.

I'll be straight with you. Courses don't really teach you anything, they just clear out the noise so you can go learn by doing. This lesson is where the doing starts.

So let's build a complete contact form, one step at a time.

Step 1: Controlled Inputs#

A controlled input is one where React is in charge of the value. You keep the value in state and update it with onChange.

import { useState } from 'react';
 
function ContactForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [message, setMessage] = useState('');
 
  return (
    <form>
      <label>
        Name
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
 
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
 
      <label>
        Message
        <textarea
          value={message}
          onChange={e => setMessage(e.target.value)}
        />
      </label>
 
      <button type="submit">Send</button>
    </form>
  );
}

Since React controls the value, you've got full power over it. You can validate on every keystroke, format text as somebody types, or block certain characters entirely.

Step 2: Form State with One Object#

Once a form has a bunch of fields, keeping all the values in one state object keeps things organized.

function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });
 
  function handleChange(event) {
    const { name, value } = event.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  }
 
  return (
    <form>
      <input name="name" value={formData.name} onChange={handleChange} />
      <input name="email" value={formData.email} onChange={handleChange} />
      <textarea name="message" value={formData.message} onChange={handleChange} />
    </form>
  );
}

The [name]: value computed property updates whichever field changed. One handler covers every input.

Step 3: Validation#

Good forms check what people type and show clear error messages when something's off. Validating when somebody leaves a field (that's "on blur") gives the best experience.

function ContactForm() {
  const [formData, setFormData] = useState({ name: '', email: '', message: '' });
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
 
  function validate(field, value) {
    switch (field) {
      case 'name':
        return value.trim() === '' ? 'Name is required' : '';
      case 'email':
        return !value.includes('@') ? 'Please enter a valid email' : '';
      case 'message':
        return value.length < 10 ? 'Message must be at least 10 characters' : '';
      default:
        return '';
    }
  }
 
  function handleBlur(event) {
    const { name, value } = event.target;
    setTouched(prev => ({ ...prev, [name]: true }));
    setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
  }
 
  function handleChange(event) {
    const { name, value } = event.target;
    setFormData(prev => ({ ...prev, [name]: value }));
    // Clear error when user starts typing again
    if (touched[name]) {
      setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
    }
  }
 
  return (
    <form>
      <div>
        <input
          name="name"
          value={formData.name}
          onChange={handleChange}
          onBlur={handleBlur}
        />
        {touched.name && errors.name && (
          <p className="error">{errors.name}</p>
        )}
      </div>
      {/* Similar for email and message */}
    </form>
  );
}

Step 1. Track Touched Fields#

Only show errors for fields the person has actually touched. NOBODY wants to get yelled at before they've even started typing.

Step 2. Validate on Blur#

When someone leaves a field, run validation and save any error message.

Step 3. Clear Errors on Change#

As soon as somebody starts fixing a field, re-validate right away so the error disappears the moment the input is good.

Step 4: Handling Submission#

Stop the default form submit (which reloads the page), validate every field, and send the data off to your API.

function ContactForm() {
  const [formData, setFormData] = useState({ name: '', email: '', message: '' });
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitStatus, setSubmitStatus] = useState(null); // 'success' | 'error' | null
 
  async function handleSubmit(event) {
    event.preventDefault();
 
    // Validate all fields before submitting
    const newErrors = {
      name: validate('name', formData.name),
      email: validate('email', formData.email),
      message: validate('message', formData.message),
    };
    setErrors(newErrors);
 
    // If any errors exist, don't submit
    if (Object.values(newErrors).some(error => error !== '')) {
      return;
    }
 
    setIsSubmitting(true);
    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });
 
      if (!response.ok) throw new Error('Failed to send');
 
      setSubmitStatus('success');
      setFormData({ name: '', email: '', message: '' }); // Reset form
    } catch (err) {
      setSubmitStatus('error');
    } finally {
      setIsSubmitting(false);
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      {/* ...form fields... */}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}

Step 5: Showing Success and Error States#

Sprout posts one envelope then slides a brass cover over the slot, holding a second envelope back
Disable the button while sending so it only goes once

Once the API responds, tell the person what happened. Use the submitStatus state to conditionally render a success message (with an option to send another one) or an inline error banner above the form. An early return for the success case swaps the whole form out for a confirmation message.

The Complete Form Pattern#

Here's a rundown of the pieces every well-built React form needs.

PieceWhat It DoesReact Feature
Controlled inputsTrack input values in real timeuseState + onChange
ValidationCheck if input is validCustom function + onBlur
Error displayShow what's wrongConditional rendering
Loading statePrevent double-submit, show spinneruseState (isSubmitting)
Submit handlerSend data to APIonSubmit + preventDefault
Success/error feedbackTell user what happenedConditional rendering

Tips for Better Forms#

  • Label all inputs - Use <label> elements or aria-label so the form is accessible
  • Use proper input types - type="email", type="password", type="number" pull up the right keyboard on mobile
  • Show character counts - For textareas with limits, show {message.length}/500
  • Auto-focus the first field - Put autoFocus on the first input for a polished feel
  • Reset carefully - Clear the form only after a confirmed success, never in the middle of submitting

TL;DR#

  • Controlled inputs keep values in state and update with onChange
  • Use a single state object with computed property names for multi-field forms
  • Validate on blur for the best experience (not too early and not too late)
  • Prevent default on form submit so the page doesn't reload
  • Disable the submit button while submitting so nothing gets sent twice
  • Show clear feedback for success, errors and loading states

What's Next?#

You just built a full React form from scratch using every core concept together. Not bad at all. In the final lesson you'll learn how to use Claude Code to build React apps, letting AI scaffold projects, generate components and help you debug a whole lot faster...

This lesson ends with a short activity.