Skip to content

Intermediate · Building the frontend · topic 14 of 34 · 30 min

Forms and client-side validation

Explanation

A React form usually keeps each input's value in state (a controlled input) and handles submit in JavaScript with preventDefault. The browser checks the same rules as the server for quick feedback, shows each error as text next to its field, and disables Save while saving. The server still checks everything, because browser checks can be skipped.

Controlled inputs

A controlled input is an input whose value comes from React state, and whose onChange handler updates that state on every key press. React is then the single source of truth: the value you validate and send is exactly what the user sees.

javascript
import { useState } from 'react';

function NoteForm({ onSave }) {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const [errors, setErrors] = useState({});
  const [saving, setSaving] = useState(false);

  async function handleSubmit(event) {
    event.preventDefault(); // stop the browser's full page reload
    const found = validateNoteForm({ title, body });
    setErrors(found);
    if (Object.keys(found).length > 0) return;
    setSaving(true);
    try {
      await onSave({ title: title.trim(), body });
    } finally {
      setSaving(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor="title">Title</label>
      <input id="title" value={title} onChange={(e) => setTitle(e.target.value)}
        required maxLength={100}
        aria-invalid={errors.title ? true : undefined}
        aria-describedby={errors.title ? 'title-error' : undefined} />
      {errors.title && <p id="title-error" className="error">{errors.title}</p>}

      <label htmlFor="body">Note</label>
      <textarea id="body" value={body} onChange={(e) => setBody(e.target.value)} maxLength={5000} />

      <button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
    </form>
  );
}
A controlled NoteForm. In JSX, the HTML attribute for becomes htmlFor and maxlength becomes maxLength.

onSubmit runs when the user presses Save or hits Enter in the title. A normal HTML form would send the data and reload the whole page. event.preventDefault() stops that, so your JavaScript can validate and send the data with fetch instead (you will connect it to the API in the next module).

Mirror the server rules

Client-side validation means checking the input in the browser before sending it. Use exactly the same rules as the backend, so the user hears about a problem at once instead of after a network round trip.

FieldRule (same as the server)Message shown
titleRequired, 1 to 100 characters after trimming spacesTitle is required / Title must be 100 characters or less
bodyOptional, up to 5000 charactersNote must be 5000 characters or less
pinnedtrue or false (a checkbox)No message needed
Think of it like this: At a bank counter, the clerk glances at your deposit slip and says "you forgot to sign here" before you even reach the queue for the cashier. That quick check saves time. But the cashier still checks the signature properly before money moves. Client checks are the clerk; server checks are the cashier. You need both.
Careful: Client-side checks are for the user's comfort, not for security. Anyone can turn off JavaScript, edit the page in DevTools, or call POST /api/notes directly with curl. The server must validate every request again and answer 400 with a VALIDATION_ERROR when the data is wrong, and the form should show that message too.

Show errors as text, linked to the field

Put each error message right next to its field, in words. Mark the field with aria-invalid="true" (tells screen readers the value is wrong) and point to the message with aria-describedby="title-error" (the screen reader reads the message when the field gets focus). A red border is fine as extra decoration, but never the only sign.

html
<label for="title">Title</label>
<input id="title" name="title" required maxlength="100"
       aria-invalid="true" aria-describedby="title-error">
<p id="title-error">Title is required</p>
The HTML React produces for a title field in the error state.
Tip: Native attributes still help: required marks the field as needed (screen readers announce it), and maxlength stops typing after 100 characters. Many teams add noValidate on the form so the browser does not show its own popup bubbles, and show their own text messages instead.

Stop double submits

On a slow network, users press Save twice. Without protection you get two identical notes. Keep a saving flag in state, set it to true when the request starts and back to false when it ends (finally runs on success and on error), and disable the button while it is true. Changing the text to "Saving..." tells the user something is happening.

React 19 form actions (good to know)

React 19 also lets you pass a function as the form's action: <form action={saveNote}>. React calls it with the form data when the form is submitted. The useActionState hook gives you the last result (for example the errors) and an isPending flag you can use to disable the button, and useFormStatus lets a child button read whether its form is pending. The ideas above (same rules, text errors, no double submit) stay the same whichever style you use.

Watch the lesson

Forms and client-side validation · 1/7

NoteForm: collect, check, send

Next we build the note form. A form collects input, checks it and sends it.

Prefer a video by a teacher? Find free videos on “react 19 forms controlled inputs validation tutorial” on YouTube ↗

In real life: A train ticket booking form

On a train booking site, the passenger form checks fields as you fill them: the name cannot be empty, the age must be a number, and the mobile number must have 10 digits. Each problem appears in red text under its field, so you fix it before pressing Book. The Book button greys out and shows "Processing" after one press, so a double tap does not book two tickets. Even so, the booking server checks every field again, because a request can be sent without using the form at all.

Practice (0/3 done)

Task 1 · Code · Javascript

Write validateNoteForm(values) for the note form. values is { title, body } (either may be missing). Return an errors object, empty {} when everything is fine. Rules: if the title after trim() is empty, errors.title = "Title is required"; if it is longer than 100 characters after trim(), errors.title = "Title must be 100 characters or less". If body is longer than 5000 characters, errors.body = "Note must be 5000 characters or less". Add title before body.

Task 2 · Code · HTML

Write the HTML of the note form in its error state. Inside a <form>: a <label for="title"> and an <input id="title" name="title"> with required and maxlength="100"; the input also has aria-invalid="true" and aria-describedby="title-error"; a <p id="title-error"> with the text "Title is required"; a <label for="body"> and a <textarea id="body" name="body" maxlength="5000">; and a <button type="submit"> with the text "Save".

Task 3 · Code · Javascript

Stop double submits in code. Write makeSaveOnce(save), where save(values) is an async function. It returns a new async function submit(values): if a save is already running, it returns the string "ignored" at once; otherwise it calls save(values) and returns its result. When save finishes (or throws), submit must accept calls again. Hint: keep a saving flag and use try/finally.

Quick quiz

Question 1

1.Why call event.preventDefault() in onSubmit?

Question 2

2.The form already checks that the title is 1 to 100 characters. Why must the server check again?

Question 3

3.What is a controlled input in React?

Question 4

4.How does a screen reader user hear "Title is required" when they reach the title field?

Question 5

5.Why disable the Save button while the note is saving?

Read more (free)

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

← Previous: Components and props: building the notes list