Home / Articles

Hand-Built React Forms: Controlled Inputs, Validation and Submit States

Learn how React forms work from controlled inputs and per-type handlers to validation, submission states, dynamic fields, uploads and when a form library pays off.

Publisher: REACTAPP.TOP13 min read2992 words

This article is published in English.

Sources: React Forms: A Complete Guide from Basics to Production Patterns

Sign-in, checkout, search, profile settings, surveys: nearly every app is built around forms. In React, a form means managing values, validation, error messages, submission, loading and server errors, fields added at runtime, resets and performance. This guide builds that picture step by step, from the controlled input to a complete registration form without a library, and shows when React Hook Form and Zod become worth adding.

How a form moves through a React app

A form collects information from the user, as in this registration screen:

┌──────────────────────────────┐
│       Create Account         │
│                              │
│ Name     [______________]    │
│ Email    [______________]    │
│ Password [______________]    │
│                              │
│         [ Register ]         │
└──────────────────────────────┘

React connects those inputs to application logic, and almost every form follows the same lifecycle:

User Input
    ↓
Form State
    ↓
Validation
    ↓
Submit
    ↓
API Request
    ↓
Success / Error

Controlled components: state as the source of truth

In a controlled component, the input's value comes from React state and every change is written back through onChange.

import { useState } from "react";

function LoginForm() {
  const [email, setEmail] = useState("");

  return (
    <input
      value={email}
      onChange={(event) => {
        setEmail(event.target.value);
      }}
    />
  );
}

Setting value={email} means React decides what the field displays. Each keystroke travels through this loop:

User types
    ↓
onChange fires
    ↓
setEmail()
    ↓
React state updates
    ↓
Component renders
    ↓
Input receives new value

Because the displayed value is always derived from state, state is the single source of truth.

The name "controlled" reflects that the input does not own its value:

<input
  value={email}
  onChange={handleChange}
/>

Data circles from state to the element, through the user's typing and the handler, and back into state:

             React State
                  │
                  ▼
              input value
                  │
                  ▼
              User types
                  │
                  ▼
              onChange
                  │
                  ▼
             React State

One state object for many fields

A useState call per field scales poorly:

const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

A single object keyed by field name is easier to maintain. This form keeps three fields in one object, shares one handleChange and logs the data on submit.

import { useState } from "react";

function RegisterForm() {
  const [form, setForm] = useState({
    name: "",
    email: "",
    password: "",
  });

  const handleChange = (event) => {
    const { name, value } = event.target;

    setForm((previous) => ({
      ...previous,
      [name]: value,
    }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();

    console.log(form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={form.name}
        onChange={handleChange}
      />

      <input
        name="email"
        value={form.email}
        onChange={handleChange}
      />

      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
      />

      <button type="submit">
        Register
      </button>
    </form>
  );
}

The key line is the computed property [name]: value. Each input's name attribute matches a state key, so one handler updates whichever field fired.

For text inputs, the handler reads the element from the event:

<input
  value={name}
  onChange={(event) => {
    setName(event.target.value);
  }}
/>

event.target is the input, so event.target.value is its current text:

User types: Salim

event.target.value
       ↓
"Salim"

Handling each input type correctly

Forms mix text, password, number, checkbox, radio, select, textarea, file, date and range controls. Most follow the same pattern, with a few important differences.

Text, password and number

A text field binds value to its key:

<input
  type="text"
  value={form.name}
  onChange={handleChange}
/>

A password field differs only in type:

<input
  type="password"
  value={form.password}
  onChange={handleChange}
/>

So does a number field:

<input
  type="number"
  value={form.age}
  onChange={handleChange}
/>

The catch: event.target.value is a string even with type="number". The snippet below mixes code and explanation, but the point is that you get "25" rather than 25 and need Number() to convert.

const age = event.target.value;

may produce:

"25" rather than: 25

If you need a number:

const age = Number(event.target.value);

Watch empty values: Number("") returns 0, so many forms keep the raw string and convert only on validation or submit.

Checkboxes use checked

For a checkbox, bind checked to a boolean and read event.target.checked:

<input
  type="checkbox"
  checked={form.agree}
  onChange={(event) => {
    setForm((previous) => ({
      ...previous,
      agree: event.target.checked,
    }));
  }}
/>

event.target.value on a checkbox returns its value attribute (by default "on"), not whether it is ticked.

Select, textarea and radio groups

A select is controlled through value on the select element; an empty first option works as a placeholder.

<select
  name="role"
  value={form.role}
  onChange={handleChange}
>
  <option value="">
    Select role
  </option>

  <option value="frontend">
    Frontend Developer
  </option>

  <option value="backend">
    Backend Developer
  </option>
</select>

React treats textarea like an input, with a value prop instead of child text:

<textarea
  name="message"
  value={form.message}
  onChange={handleChange}
/>

Radio buttons in a group share a name. Each is checked when its value matches state, and the shared handler stores the choice in form.gender.

<label>
  <input
    type="radio"
    name="gender"
    value="male"
    checked={form.gender === "male"}
    onChange={handleChange}
  />
  Male
</label>

<label>
  <input
    type="radio"
    name="gender"
    value="female"
    checked={form.gender === "female"}
    onChange={handleChange}
  />
  Female
</label>

Taking over submission

By default a browser submits the form itself and may navigate or reload. React apps handle submission in JavaScript, so the submit handler begins with event.preventDefault(), as in the registration example. Then the flow is yours:

Submit
  ↓
preventDefault()
  ↓
Validate
  ↓
API
  ↓
Update UI

Validating before the request

Validation checks that input is acceptable. A first version collects errors keyed by field:

const validate = () => {
  const errors = {};

  if (!form.name.trim()) {
    errors.name = "Name is required";
  }

  if (!form.email.trim()) {
    errors.email = "Email is required";
  }

  if (!form.password) {
    errors.password = "Password is required";
  }

  return errors;
};

The submit handler validates first, stores errors and exits early when any exist:

const handleSubmit = (event) => {
  event.preventDefault();

  const validationErrors = validate();

  if (Object.keys(validationErrors).length > 0) {
    setErrors(validationErrors);
    return;
  }

  // Submit form
};

Errors live in their own state:

const [errors, setErrors] = useState({});

At a given moment it might contain:

{
  email: "Invalid email",
  password: "Password must contain at least 8 characters"
}

Each field renders its message conditionally:

{errors.email && (

  <p>{errors.email}</p>

)}

A better validate takes the form as an argument, making it pure and testable, and checks format and length once a field is present:

function validate(form) {
  const errors = {};

  if (!form.name.trim()) {
    errors.name = "Name is required";
  }

  if (!form.email.trim()) {
    errors.email = "Email is required";
  } else if (!form.email.includes("@")) {
    errors.email = "Invalid email";
  }

  if (!form.password) {
    errors.password = "Password is required";
  } else if (form.password.length < 8) {
    errors.password =
      "Password must contain at least 8 characters";
  }

  return errors;
}

The handler calls it the same way:

const errors = validate(form);

if (Object.keys(errors).length > 0) {
  setErrors(errors);
  return;
}

The includes("@") test only catches obvious typos; the real proof that an address works is a confirmation email.

Client and server validation do different jobs

Client-side validation runs in the browser and typically covers:

  1. Required fields
  2. Email format
  3. Minimum password length
  4. Password confirmation

It gives fast feedback, a better experience and fewer wasted requests, but it is not a security boundary. The server must repeat the checks and answer questions the browser cannot:

  1. Is the email already registered?
  2. Is the user authorized?
  3. Is the price valid?
  4. May this user perform this operation?

Secure applications use both. Never write logic like this:

if (clientValidationPassed) {
  // Data is safe
}

Browser checks can be bypassed by editing the page or calling the API directly, so the backend validates every request independently:

Request
  ↓
Authentication
  ↓
Authorization
  ↓
Validation
  ↓
Business Logic
  ↓
Database

Modeling submission states

A production form moves through distinct states the UI should reflect:

Idle
 ↓
Submitting
 ↓
Success

or

Idle
 ↓
Submitting
 ↓
Error

Two pieces of state track them:

const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");

The async handler raises the flag, clears old server errors, awaits the request, records a friendly message on failure, and resets the flag in finally either way.

const handleSubmit = async (event) => {
  event.preventDefault();

  setIsSubmitting(true);
  setServerError("");

  try {
    await createUser(form);
  } catch (error) {
    setServerError("Unable to create account");
  } finally {
    setIsSubmitting(false);
  }
};

Disabling and relabeling the button during the request prevents duplicate submissions:

<button
  type="submit"
  disabled={isSubmitting}
>
  {isSubmitting
    ? "Creating..."
    : "Create Account"}
</button>

The button's lifecycle mirrors the request:

User clicks Submit
      ↓
Request starts
      ↓
Button disabled
      ↓
Request finishes
      ↓
Button enabled

Controlled versus uncontrolled inputs

With a controlled input, React state holds the value. The block below shows the short form, a full component and the data flow, separated by lines of asterisks.

<input
  value={email}
  onChange={handleChange}
/>

********************************************

function Form() {
  const [email, setEmail] = useState("");

  return (
    <input
      value={email}
      onChange={(event) =>
        setEmail(event.target.value)
      }
    />
  );
}

********************************************



DOM event
   ↓
React state
   ↓
Render
   ↓
DOM value

With an uncontrolled input, the DOM keeps the value and React reads it through a ref, typically on submit, without storing each keystroke:

const emailRef = useRef();

<input ref={emailRef} />

********************************************

function Form() {
  const emailRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault();

    console.log(emailRef.current.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input ref={emailRef} />

      <button type="submit">
        Submit
      </button>
    </form>
  );
}

********************************************

DOM
 ↓
Input value

Choose controlled inputs for immediate validation, conditional UI, derived values, formatting, React-driven state or custom interactions. Because React knows the value, the UI can react instantly:

{password.length < 8 && (
  <p>Password is too short</p>
)}

Choose uncontrolled inputs for simple forms, when per-keystroke state adds nothing, when you want fewer updates, or when a library manages refs for you. React 19 form actions also build on this model; see async forms with useActionState and useOptimistic.

When a form library earns its place

React Hook Form

Wiring dozens of fields by hand is tedious. React Hook Form's register connects an input and declares its rules, handleSubmit validates before calling your callback, and formState.errors exposes messages.

import { useForm } from "react-hook-form";

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm();

  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("email", {
          required: "Email is required",
        })}
      />

      {errors.email && (
        <p>{errors.email.message}</p>
      )}

      <input
        type="password"
        {...register("password", {
          required: "Password is required",
          minLength: {
            value: 8,
            message:
              "Password must be at least 8 characters",
          },
        })}
      />

      {errors.password && (
        <p>{errors.password.message}</p>
      )}

      <button type="submit">
        Login
      </button>
    </form>
  );
}

It simplifies registration, validation, errors, submission, resets, dynamic fields and form state. Because it registers inputs uncontrolled by default, it avoids re-rendering the whole form per keystroke as a naive controlled form does.

Schema validation with Zod

As rules multiply, scattered if statements drift apart. Zod describes the expected data shape in one declaration:

import { z } from "zod";

const userSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters"),

  email: z
    .string()
    .email("Invalid email"),

  password: z
    .string()
    .min(8, "Password must be at least 8 characters"),
});

A common production pairing uses the library for state and the schema for rules:

React Hook Form
       +
      Zod
       ↓
Form state + validation

The schema can be this compact:

const formSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

React Hook Form plugs a schema in through a resolver, which suits large forms whose rules must stay consistent.

Dynamic, conditional and dependent fields

Repeating groups

Users often add entries on demand, such as education records:

Education

Degree  [________]
College [________]

[ + Add Education ]

Store them as an array of objects:

const [education, setEducation] = useState([
  {
    degree: "",
    college: "",
  },
]);

Adding a row appends an empty object:

setEducation((previous) => [
  ...previous,
  {
    degree: "",
    college: "",
  },
]);

Rendering maps over the array:

{education.map((item, index) => (
  <div key={index}>
    <input
      value={item.degree}
      onChange={(event) => {
        // update degree
      }}
    />

    <input
      value={item.college}
      onChange={(event) => {
        // update college
      }}
    />
  </div>
))}

An index key is only safe while rows are never removed or reordered; otherwise use stable ids, or typed text can attach to the wrong row.

Conditional fields

Some fields depend on an earlier choice, such as company details for a Business account:

Account Type

○ Individual
○ Business

If the user chooses:

Business

show:
Company Name
GST Number
Company Address

A conditional expression handles it:

{form.accountType === "business" && (
  <>
    <input
      name="companyName"
      value={form.companyName}
      onChange={handleChange}
    />

    <input
      name="gstNumber"
      value={form.gstNumber}
      onChange={handleChange}
    />
  </>
)}

If the user switches back, clear or exclude the hidden values so stale data is not submitted.

Cascading selections

Country, state and city form a chain:

Country
   ↓
State
   ↓
City

A change upstream resets everything below it before fetching new options:

Country changes
      ↓
Clear State
      ↓
Fetch states
      ↓
User selects State
      ↓
Fetch cities

Since the options come from the server, TanStack Query pairs well with the form library for caching and loading states.

File uploads

File inputs cannot be controlled; read the file from event.target.files:

<input
  type="file"
  onChange={(event) => {
    const file = event.target.files?.[0];

    if (file) {
      console.log(file);
    }
  }}
/>

To upload it, send a FormData body:

const formData = new FormData();

formData.append("name", form.name);
formData.append("file", file);

await fetch("/api/upload", {
  method: "POST",
  body: formData,
});

Do not set Content-Type: multipart/form-data yourself with fetch and FormData; the browser generates that header with the required boundary.

Keeping large forms fast

Large, complex forms strain a naive controlled form that re-renders on every keystroke:

User types
   ↓
setState
   ↓
Render
   ↓
Large form tree

Useful countermeasures:

  1. Keep state local rather than global without reason.
  2. Split the form into sections so each change re-renders less:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
  1. Use a suitable library such as React Hook Form.
  2. Avoid heavy calculations or API calls per character.
  3. Debounce what must run, such as a username availability check:
Username input
     ↓
Wait 300ms
     ↓
Check availability

A complete form without a library

The final example combines an initialForm constant for resets, separate state for field errors, submission and server errors, a validate function, and an async submit that posts JSON, treats a non-OK response as failure, resets on success and always clears the loading flag. Labels link to inputs via htmlFor and id.

import { useState } from "react";

const initialForm = {
  name: "",
  email: "",
  password: "",
};

function RegisterForm() {
  const [form, setForm] = useState(initialForm);
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] =
    useState(false);
  const [serverError, setServerError] =
    useState("");

  const handleChange = (event) => {
    const { name, value } = event.target;

    setForm((previous) => ({
      ...previous,
      [name]: value,
    }));
  };

  const validate = () => {
    const errors = {};

    if (!form.name.trim()) {
      errors.name = "Name is required";
    }

    if (!form.email.includes("@")) {
      errors.email = "Enter a valid email";
    }

    if (form.password.length < 8) {
      errors.password =
        "Password must be at least 8 characters";
    }

    return errors;
  };

  const handleSubmit = async (event) => {
    event.preventDefault();

    const validationErrors = validate();

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }

    setErrors({});
    setServerError("");
    setIsSubmitting(true);

    try {
      const response = await fetch("/api/register", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(form),
      });

      if (!response.ok) {
        throw new Error("Registration failed");
      }

      setForm(initialForm);
    } catch (error) {
      setServerError(
        "Unable to create your account."
      );
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">
          Name
        </label>

        <input
          id="name"
          name="name"
          value={form.name}
          onChange={handleChange}
        />

        {errors.name && (
          <p>{errors.name}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">
          Email
        </label>

        <input
          id="email"
          name="email"
          type="email"
          value={form.email}
          onChange={handleChange}
        />

        {errors.email && (
          <p>{errors.email}</p>
        )}
      </div>

      <div>
        <label htmlFor="password">
          Password
        </label>

        <input
          id="password"
          name="password"
          type="password"
          value={form.password}
          onChange={handleChange}
        />

        {errors.password && (
          <p>{errors.password}</p>
        )}
      </div>

      {serverError && (
        <p>{serverError}</p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
      >
        {isSubmitting
          ? "Creating..."
          : "Create Account"}
      </button>
    </form>
  );
}

export default RegisterForm;

For production, also set aria-invalid on invalid fields and link messages with aria-describedby.

The whole flow at a glance

Everything above fits one decision tree, from input through validation to field errors or an API call with success and error branches:

                    React Form
                        │
                        ▼
                   User Input
                        │
                        ▼
                    Form State
                        │
                        ▼
                    Validation
                   /          \
                Valid        Invalid
                  │              │
                  ▼              ▼
               Submit        Show Errors
                  │
                  ▼
               API Call
              /        \
          Success      Error
             │           │
             ▼           ▼
        Reset/Form    Show Server
        Success UI       Error

Key takeaways

  • Controlled inputs make state the source of truth; one object plus a [name]: value handler covers most fields.
  • Numbers arrive as strings, checkboxes use checked, selects are controlled on the select element, and file inputs are always uncontrolled.
  • Client validation serves the user; only server validation protects data.
  • Model submission states explicitly and disable submit during requests.
  • Add React Hook Form and Zod when field count, shared rules or render cost make hand-written state painful.