DEV Community

Karthik Reddy
Karthik Reddy

Posted on

The Evolution of Web Forms — Part 2

The Evolution of Web Forms — Part 2: From React State to Form Libraries

In Part 1, we built forms using:

  1. Plain HTML
  2. Native HTML validation
  3. Vanilla JavaScript validation
  4. AJAX submission

Our AJAX registration form could:

  • Read input values
  • Validate fields
  • Display errors
  • Submit JSON
  • Show a loading state
  • Handle backend errors
  • Reset after success

That sounds complete.

However, the form had only three fields.

Real production forms may contain dozens of fields, conditional sections, nested objects, uploaded files, dynamic arrays, and multiple validation states.

At that scale, manual DOM manipulation becomes difficult to maintain.

This part covers the next four stages:

  1. The scaling problems of manual forms
  2. React controlled forms
  3. Large React forms and state explosion
  4. The rise of form libraries

The goal is not merely to learn a different syntax.

The goal is to understand why React improved form development—and why React itself did not completely solve the form problem.


Stage 5: Forms Begin to Scale

Consider a simple login form:

Email
Password
Enter fullscreen mode Exit fullscreen mode

It needs only:

2 values
2 possible field errors
1 loading state
1 general server error
Enter fullscreen mode Exit fullscreen mode

Now consider an employee onboarding form.

Personal information
├── First name
├── Last name
├── Email
├── Phone number
├── Date of birth
└── Profile image

Address
├── Country
├── State
├── City
├── Street
└── Postal code

Employment
├── Job title
├── Department
├── Joining date
├── Employment type
└── Manager

Emergency contacts
├── Contact 1
│   ├── Name
│   ├── Relationship
│   └── Phone
└── Contact 2
    ├── Name
    ├── Relationship
    └── Phone

Documents
├── Identity proof
├── Address proof
└── Resume
Enter fullscreen mode Exit fullscreen mode

This is no longer “a few inputs.”

It is a small state-management system.


The hidden state inside every field

An input usually has more state than its value.

For an email field, we may need to know:

{
  value: "karthik@example.com",
  error: "",
  touched: true,
  dirty: true,
  disabled: false,
  validating: false
}
Enter fullscreen mode Exit fullscreen mode

These properties answer different questions.

value

What is currently inside the field?

karthik@example.com
Enter fullscreen mode Exit fullscreen mode

error

Is the value invalid?

Email already exists.
Enter fullscreen mode Exit fullscreen mode

touched

Has the user interacted with and left the field?

The user focused the field and then blurred it.
Enter fullscreen mode Exit fullscreen mode

dirty

Is the current value different from its initial value?

Initial value: ""
Current value: "karthik@example.com"
Enter fullscreen mode Exit fullscreen mode

disabled

Should the user be allowed to edit it?

validating

Is an asynchronous validation request running?

For example:

Checking whether this username is available...
Enter fullscreen mode Exit fullscreen mode

A 20-field form can therefore involve far more than 20 pieces of state.


Problem 1: Repeated DOM queries

In Vanilla JavaScript, we might write:

const firstNameInput =
  document.getElementById("firstName");

const lastNameInput =
  document.getElementById("lastName");

const emailInput =
  document.getElementById("email");

const phoneInput =
  document.getElementById("phone");

const countryInput =
  document.getElementById("country");

const cityInput =
  document.getElementById("city");
Enter fullscreen mode Exit fullscreen mode

Then we also need the corresponding error elements:

const firstNameError =
  document.getElementById("firstName-error");

const lastNameError =
  document.getElementById("lastName-error");

const emailError =
  document.getElementById("email-error");

const phoneError =
  document.getElementById("phone-error");
Enter fullscreen mode Exit fullscreen mode

For every field, we repeatedly:

  1. Find the input.
  2. Find the error element.
  3. Read the value.
  4. Validate the value.
  5. Update accessibility attributes.
  6. Display or remove the error.

Problem 2: Validation duplication

A large form may contain code like:

if (!firstName) {
  showError(
    firstNameInput,
    firstNameError,
    "First name is required."
  );
}

if (!lastName) {
  showError(
    lastNameInput,
    lastNameError,
    "Last name is required."
  );
}

if (!email) {
  showError(
    emailInput,
    emailError,
    "Email is required."
  );
}

if (!phone) {
  showError(
    phoneInput,
    phoneError,
    "Phone number is required."
  );
}
Enter fullscreen mode Exit fullscreen mode

The field names change, but the structure remains almost identical.

Repeated code creates several risks:

  • A developer forgets to clear one error.
  • One field uses a different error convention.
  • Accessibility attributes are applied inconsistently.
  • A validation rule is updated in one place but not another.
  • Backend field errors are mapped differently across forms.

Problem 3: Nested values

Simple forms contain flat values:

{
  firstName: "Karthik",
  email: "karthik@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Production forms often contain nested data:

{
  firstName: "Karthik",
  email: "karthik@example.com",
  address: {
    country: "India",
    state: "Andhra Pradesh",
    city: "Kadapa"
  },
  emergencyContact: {
    name: "Example Name",
    relationship: "Parent",
    phone: "9999999999"
  }
}
Enter fullscreen mode Exit fullscreen mode

Reading and updating nested values manually is harder.

formData.address.city = cityInput.value;
Enter fullscreen mode Exit fullscreen mode

Error structures also become nested:

{
  address: {
    city: "City is required."
  }
}
Enter fullscreen mode Exit fullscreen mode

The frontend must connect:

errors.address.city
Enter fullscreen mode Exit fullscreen mode

to:

address.city input
Enter fullscreen mode Exit fullscreen mode

Problem 4: Dynamic fields

Suppose a candidate can add multiple work experiences.

Work Experience 1
├── Company
├── Role
├── Start date
└── End date

+ Add another experience
Enter fullscreen mode Exit fullscreen mode

After clicking the button:

Work Experience 1
Work Experience 2
Enter fullscreen mode Exit fullscreen mode

The final data might look like:

{
  experiences: [
    {
      company: "ABC Technologies",
      role: "Intern",
      startDate: "2025-01-01",
      endDate: "2025-06-01"
    },
    {
      company: "XYZ Software",
      role: "Developer",
      startDate: "2025-07-01",
      endDate: "2026-02-01"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now the form must support:

  • Adding fields
  • Removing fields
  • Reordering fields
  • Validating each item
  • Preserving stable identifiers
  • Mapping nested errors
  • Preventing one field's error from appearing under another item

An error might exist at:

experiences[1].company
Enter fullscreen mode Exit fullscreen mode

That means:

The company field
inside the second work-experience item
Enter fullscreen mode Exit fullscreen mode

Manual DOM code becomes increasingly fragile.


Problem 5: Checkboxes

Checkboxes behave differently from text fields.

A single checkbox may represent a Boolean:

<input
  type="checkbox"
  name="acceptTerms"
/>
Enter fullscreen mode Exit fullscreen mode

Result:

{
  acceptTerms: true
}
Enter fullscreen mode Exit fullscreen mode

A checkbox group may represent an array:

Skills:
[x] JavaScript
[x] React
[ ] Angular
Enter fullscreen mode Exit fullscreen mode

Result:

{
  skills: ["javascript", "react"]
}
Enter fullscreen mode Exit fullscreen mode

Developers must decide whether to read:

checkbox.checked
Enter fullscreen mode Exit fullscreen mode

or:

checkbox.value
Enter fullscreen mode Exit fullscreen mode

and then construct the final array correctly.


Problem 6: Radio buttons

Radio buttons usually represent one value from a group.

<label>
  <input
    type="radio"
    name="employmentType"
    value="full-time"
  />

  Full time
</label>

<label>
  <input
    type="radio"
    name="employmentType"
    value="part-time"
  />

  Part time
</label>
Enter fullscreen mode Exit fullscreen mode

The shared name connects the buttons into one group.

The form value should become:

{
  employmentType: "full-time"
}
Enter fullscreen mode Exit fullscreen mode

Validation must treat the group as one logical field, even though it contains multiple HTML inputs.


Problem 7: File uploads

File inputs do not behave exactly like text inputs.

<input
  id="resume"
  name="resume"
  type="file"
/>
Enter fullscreen mode Exit fullscreen mode

The selected value is accessed through:

resumeInput.files
Enter fullscreen mode Exit fullscreen mode

not:

resumeInput.value
Enter fullscreen mode Exit fullscreen mode

A request containing files commonly uses FormData:

const formData = new FormData();

formData.append("firstName", firstName);
formData.append("email", email);
formData.append("resume", resumeInput.files[0]);
Enter fullscreen mode Exit fullscreen mode

File validation may include:

  • File type
  • File size
  • Number of files
  • Image dimensions
  • Upload progress
  • Upload cancellation
  • Virus-scanning status
  • Server-side storage failure

Problem 8: Conditional fields

Suppose the form asks:

Are you currently employed?

( ) Yes
( ) No
Enter fullscreen mode Exit fullscreen mode

When the user selects Yes, display:

Current company
Current role
Notice period
Enter fullscreen mode Exit fullscreen mode

When the user selects No, those fields disappear.

Now we must answer:

  • Should hidden values be preserved?
  • Should hidden fields still be validated?
  • Should their errors be removed?
  • Should they be included in the request?
  • What happens if the user selects Yes, enters data, and then selects No?

Conditional forms require explicit state rules.


Problem 9: Cross-field validation

Some rules depend on multiple fields.

Password confirmation

password === confirmPassword
Enter fullscreen mode Exit fullscreen mode

Date range

startDate <= endDate
Enter fullscreen mode Exit fullscreen mode

Conditional requirement

If employmentType is "employed",
currentCompany is required.
Enter fullscreen mode Exit fullscreen mode

At least one selection

At least one preferred communication method
must be selected.
Enter fullscreen mode Exit fullscreen mode

Field-by-field validation is no longer enough.


Problem 10: Asynchronous validation

Suppose the user enters:

Username: karthik
Enter fullscreen mode Exit fullscreen mode

The frontend sends:

GET /api/users/check-username?username=karthik
Enter fullscreen mode Exit fullscreen mode

Possible response:

{
  "available": false
}
Enter fullscreen mode Exit fullscreen mode

The form now needs to track:

Idle
  ↓
Checking username...
  ↓
Available or unavailable
Enter fullscreen mode Exit fullscreen mode

What if the user types quickly?

k
ka
kar
kart
karthik
Enter fullscreen mode Exit fullscreen mode

Without debouncing or cancellation, the browser may send many requests.

Responses can also arrive out of order.

Request A: "kart"
Request B: "karthik"

Response B arrives first.
Response A arrives later.
Enter fullscreen mode Exit fullscreen mode

The older response must not replace the newer result.


Complete example: A scaling Vanilla JavaScript form

The following example is still manageable, but notice how much infrastructure is required for only ten fields.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />

    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />

    <title>Large Vanilla JavaScript Form</title>

    <style>
      body {
        max-width: 700px;
        margin: 40px auto;
        padding: 0 16px;
        font-family: Arial, sans-serif;
      }

      form {
        display: grid;
        gap: 18px;
      }

      .row {
        display: grid;
        grid-template-columns: repeat(2, 1fr);
        gap: 16px;
      }

      .field {
        display: flex;
        flex-direction: column;
        gap: 6px;
      }

      .full-width {
        grid-column: 1 / -1;
      }

      input,
      select,
      textarea,
      button {
        padding: 10px;
        font: inherit;
      }

      input[aria-invalid="true"],
      select[aria-invalid="true"],
      textarea[aria-invalid="true"] {
        border: 2px solid #b91c1c;
      }

      .error {
        min-height: 18px;
        margin: 0;
        color: #b91c1c;
        font-size: 14px;
      }

      .success {
        color: #15803d;
      }

      @media (max-width: 600px) {
        .row {
          grid-template-columns: 1fr;
        }
      }
    </style>
  </head>

  <body>
    <h1>Employee onboarding</h1>

    <form id="employee-form" novalidate>
      <div class="row">
        <div class="field">
          <label for="firstName">First name</label>

          <input
            id="firstName"
            name="firstName"
            type="text"
            aria-describedby="firstName-error"
          />

          <p
            id="firstName-error"
            class="error"
          ></p>
        </div>

        <div class="field">
          <label for="lastName">Last name</label>

          <input
            id="lastName"
            name="lastName"
            type="text"
            aria-describedby="lastName-error"
          />

          <p
            id="lastName-error"
            class="error"
          ></p>
        </div>
      </div>

      <div class="row">
        <div class="field">
          <label for="email">Email</label>

          <input
            id="email"
            name="email"
            type="email"
            aria-describedby="email-error"
          />

          <p
            id="email-error"
            class="error"
          ></p>
        </div>

        <div class="field">
          <label for="phone">Phone</label>

          <input
            id="phone"
            name="phone"
            type="tel"
            aria-describedby="phone-error"
          />

          <p
            id="phone-error"
            class="error"
          ></p>
        </div>
      </div>

      <div class="row">
        <div class="field">
          <label for="country">Country</label>

          <select
            id="country"
            name="country"
            aria-describedby="country-error"
          >
            <option value="">Select a country</option>
            <option value="india">India</option>
            <option value="usa">United States</option>
            <option value="uk">United Kingdom</option>
          </select>

          <p
            id="country-error"
            class="error"
          ></p>
        </div>

        <div class="field">
          <label for="city">City</label>

          <input
            id="city"
            name="city"
            type="text"
            aria-describedby="city-error"
          />

          <p
            id="city-error"
            class="error"
          ></p>
        </div>
      </div>

      <div class="row">
        <div class="field">
          <label for="jobTitle">Job title</label>

          <input
            id="jobTitle"
            name="jobTitle"
            type="text"
            aria-describedby="jobTitle-error"
          />

          <p
            id="jobTitle-error"
            class="error"
          ></p>
        </div>

        <div class="field">
          <label for="joiningDate">
            Joining date
          </label>

          <input
            id="joiningDate"
            name="joiningDate"
            type="date"
            aria-describedby="joiningDate-error"
          />

          <p
            id="joiningDate-error"
            class="error"
          ></p>
        </div>
      </div>

      <div class="field">
        <label for="bio">Short biography</label>

        <textarea
          id="bio"
          name="bio"
          rows="4"
          aria-describedby="bio-error"
        ></textarea>

        <p
          id="bio-error"
          class="error"
        ></p>
      </div>

      <div class="field">
        <label>
          <input
            id="acceptTerms"
            name="acceptTerms"
            type="checkbox"
            aria-describedby="acceptTerms-error"
          />

          I accept the employment terms
        </label>

        <p
          id="acceptTerms-error"
          class="error"
        ></p>
      </div>

      <button id="submit-button" type="submit">
        Submit employee details
      </button>

      <p
        id="form-message"
        role="status"
        aria-live="polite"
      ></p>
    </form>

    <script>
      const form =
        document.getElementById("employee-form");

      const submitButton =
        document.getElementById("submit-button");

      const formMessage =
        document.getElementById("form-message");

      const fieldNames = [
        "firstName",
        "lastName",
        "email",
        "phone",
        "country",
        "city",
        "jobTitle",
        "joiningDate",
        "bio",
        "acceptTerms",
      ];

      const fields = Object.fromEntries(
        fieldNames.map((fieldName) => [
          fieldName,
          {
            input: document.getElementById(fieldName),

            error: document.getElementById(
              `${fieldName}-error`
            ),
          },
        ])
      );

      const emailPattern =
        /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

      const phonePattern =
        /^[0-9+\-\s]{8,15}$/;

      function getValues() {
        return {
          firstName:
            fields.firstName.input.value.trim(),

          lastName:
            fields.lastName.input.value.trim(),

          email:
            fields.email.input.value
              .trim()
              .toLowerCase(),

          phone:
            fields.phone.input.value.trim(),

          address: {
            country:
              fields.country.input.value,

            city:
              fields.city.input.value.trim(),
          },

          employment: {
            jobTitle:
              fields.jobTitle.input.value.trim(),

            joiningDate:
              fields.joiningDate.input.value,
          },

          bio:
            fields.bio.input.value.trim(),

          acceptTerms:
            fields.acceptTerms.input.checked,
        };
      }

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

        if (!values.firstName) {
          errors.firstName =
            "First name is required.";
        }

        if (!values.lastName) {
          errors.lastName =
            "Last name is required.";
        }

        if (!values.email) {
          errors.email = "Email is required.";
        } else if (
          !emailPattern.test(values.email)
        ) {
          errors.email =
            "Enter a valid email address.";
        }

        if (!values.phone) {
          errors.phone =
            "Phone number is required.";
        } else if (
          !phonePattern.test(values.phone)
        ) {
          errors.phone =
            "Enter a valid phone number.";
        }

        if (!values.address.country) {
          errors.country =
            "Country is required.";
        }

        if (!values.address.city) {
          errors.city = "City is required.";
        }

        if (!values.employment.jobTitle) {
          errors.jobTitle =
            "Job title is required.";
        }

        if (!values.employment.joiningDate) {
          errors.joiningDate =
            "Joining date is required.";
        }

        if (values.bio.length > 300) {
          errors.bio =
            "Biography cannot exceed 300 characters.";
        }

        if (!values.acceptTerms) {
          errors.acceptTerms =
            "You must accept the terms.";
        }

        return errors;
      }

      function showError(fieldName, message) {
        const field = fields[fieldName];

        if (!field) {
          return;
        }

        field.input.setAttribute(
          "aria-invalid",
          "true"
        );

        field.error.textContent = message;
      }

      function clearError(fieldName) {
        const field = fields[fieldName];

        if (!field) {
          return;
        }

        field.input.setAttribute(
          "aria-invalid",
          "false"
        );

        field.error.textContent = "";
      }

      function clearErrors() {
        fieldNames.forEach(clearError);
      }

      function renderErrors(errors) {
        Object.entries(errors).forEach(
          ([fieldName, message]) => {
            showError(fieldName, message);
          }
        );
      }

      function focusFirstError(errors) {
        const firstFieldName =
          Object.keys(errors)[0];

        fields[firstFieldName]?.input.focus();
      }

      function setSubmitting(isSubmitting) {
        submitButton.disabled = isSubmitting;

        submitButton.textContent = isSubmitting
          ? "Submitting..."
          : "Submit employee details";

        fieldNames.forEach((fieldName) => {
          fields[fieldName].input.disabled =
            isSubmitting;
        });
      }

      form.addEventListener(
        "submit",
        async function (event) {
          event.preventDefault();

          clearErrors();

          formMessage.textContent = "";
          formMessage.className = "";

          const values = getValues();
          const errors = validate(values);

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

          setSubmitting(true);

          try {
            await new Promise((resolve) => {
              setTimeout(resolve, 1000);
            });

            console.log(
              "Submitted employee:",
              values
            );

            formMessage.textContent =
              "Employee details submitted.";

            formMessage.className = "success";

            form.reset();
          } catch (error) {
            console.error(error);

            formMessage.textContent =
              "Unable to submit the form.";
          } finally {
            setSubmitting(false);
          }
        }
      );

      fieldNames.forEach((fieldName) => {
        fields[
          fieldName
        ].input.addEventListener(
          "input",
          function () {
            clearError(fieldName);
          }
        );

        fields[
          fieldName
        ].input.addEventListener(
          "change",
          function () {
            clearError(fieldName);
          }
        );
      });
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This form works.

But ask yourself:

  • Where is the current form state?
  • Which fields have been touched?
  • Which values are dirty?
  • How do other UI components access the values?
  • How do we add conditional fields?
  • How do we reuse this logic?
  • How do we test DOM-manipulation code?
  • How do we keep the interface synchronized with the data?

The values live primarily inside the DOM.

JavaScript repeatedly reads the DOM and manually updates it.

DOM stores values
      ↓
JavaScript reads DOM
      ↓
JavaScript performs logic
      ↓
JavaScript manually changes DOM
Enter fullscreen mode Exit fullscreen mode

This is an imperative model.


Imperative UI programming

Imperative code tells the browser exactly what to change.

emailError.textContent =
  "Email already exists.";

emailInput.setAttribute(
  "aria-invalid",
  "true"
);

submitButton.disabled = true;

submitButton.textContent =
  "Submitting...";
Enter fullscreen mode Exit fullscreen mode

We give individual instructions:

Change this text.
Disable this button.
Add this attribute.
Focus this input.
Remove this message.
Enter fullscreen mode Exit fullscreen mode

As the number of possible states increases, it becomes harder to guarantee that every DOM element displays the correct state.

A developer might update the error text but forget to update aria-invalid.

Another might enable the button but forget to re-enable the fields.


The desired solution

Developers wanted to describe the interface as a result of state.

Instead of manually saying:

submitButton.disabled = true;
submitButton.textContent = "Submitting...";
Enter fullscreen mode Exit fullscreen mode

we want to express:

<button disabled={isSubmitting}>
  {isSubmitting
    ? "Submitting..."
    : "Submit"}
</button>
Enter fullscreen mode Exit fullscreen mode

The idea becomes:

State changes
     ↓
UI is recalculated from state
     ↓
Framework updates the required DOM
Enter fullscreen mode Exit fullscreen mode

That declarative approach is one of React's central ideas: rather than manually changing separate DOM elements, developers describe what the UI should look like for the current state.


Advantages of the manual approach

  • No framework dependency
  • Direct access to browser APIs
  • Efficient for small interactions
  • Full control over individual DOM updates
  • Works in almost every browser environment
  • Useful for understanding browser fundamentals

Disadvantages of the manual approach

  • Repetitive DOM queries
  • State is scattered across DOM elements and variables
  • Difficult synchronization
  • Imperative updates can become inconsistent
  • Nested and dynamic fields are difficult
  • Reusability requires custom abstractions
  • Testing becomes more complicated
  • Large forms produce substantial boilerplate

Common beginner mistake

A common mistake is believing that React was invented specifically to handle forms.

React is a UI library, not a complete form-management system.

React improves how changing UI is represented, but developers must still design:

  • Form values
  • Errors
  • Touched state
  • Dirty state
  • Submission state
  • Validation timing
  • Backend error handling

Interview question

What does “declarative UI” mean?

Declarative UI means describing what the interface should look like for the current state instead of manually specifying every DOM operation required to reach that state.

Imperative:

button.disabled = true;
button.textContent = "Saving...";
Enter fullscreen mode Exit fullscreen mode

Declarative:

<button disabled={isSaving}>
  {isSaving ? "Saving..." : "Save"}
</button>
Enter fullscreen mode Exit fullscreen mode

Why this evolved

Manual JavaScript forms worked, but their state was distributed across DOM nodes, variables, and event handlers. As applications became more interactive, developers needed a predictable way to derive the interface from application state. React provided that declarative model.


Stage 6: React Controlled Forms

React allows us to build interfaces from components and state.

Instead of asking:

What DOM element should I manually change?
Enter fullscreen mode Exit fullscreen mode

we ask:

What is the current state?

What should the UI look like for that state?
Enter fullscreen mode Exit fullscreen mode

React then updates the required DOM elements.


Controlled components

Consider this React input:

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

return (
  <input
    type="email"
    value={email}
    onChange={(event) => {
      setEmail(event.target.value);
    }}
  />
);
Enter fullscreen mode Exit fullscreen mode

This is a controlled input.

React calls an input controlled when its displayed value is driven by React state. The official React documentation also notes that controlled inputs update state on every keystroke.

The three important pieces are:

const [email, setEmail] = useState("");
Enter fullscreen mode Exit fullscreen mode
value={email}
Enter fullscreen mode Exit fullscreen mode
onChange={(event) => {
  setEmail(event.target.value);
}}
Enter fullscreen mode Exit fullscreen mode

Understanding useState

const [email, setEmail] = useState("");
Enter fullscreen mode Exit fullscreen mode

This creates:

email
→ current state value

setEmail
→ function used to request a state update

""
→ initial value
Enter fullscreen mode Exit fullscreen mode

Initially:

email = ""
Enter fullscreen mode Exit fullscreen mode

After the user types:

k
Enter fullscreen mode Exit fullscreen mode

we call:

setEmail("k");
Enter fullscreen mode Exit fullscreen mode

React runs the component again using the new state.


Understanding value

value={email}
Enter fullscreen mode Exit fullscreen mode

The input does not independently decide its displayed value.

React state provides the value.

React state
    ↓
input value
Enter fullscreen mode Exit fullscreen mode

If state contains:

karthik@example.com
Enter fullscreen mode Exit fullscreen mode

the input displays:

karthik@example.com
Enter fullscreen mode Exit fullscreen mode

Understanding onChange

onChange={(event) => {
  setEmail(event.target.value);
}}
Enter fullscreen mode Exit fullscreen mode

When the user types, the input emits a change event.

React receives the event and reads:

event.target.value
Enter fullscreen mode Exit fullscreen mode

Then state is updated.

User types "k"
       ↓
onChange fires
       ↓
event.target.value is "k"
       ↓
setEmail("k")
       ↓
React component runs again
       ↓
value={email} becomes "k"
       ↓
Input displays "k"
Enter fullscreen mode Exit fullscreen mode

The controlled-input loop

        User types
            ↓
      onChange executes
            ↓
    Update React state
            ↓
 Component function runs
            ↓
React creates next UI description
            ↓
 React updates required DOM
            ↓
      Input shows value
Enter fullscreen mode Exit fullscreen mode

React state becomes the single source of truth.


What does “single source of truth” mean?

Without controlled state, values may exist in several places:

DOM input
JavaScript variable
Validation object
Submission object
Enter fullscreen mode Exit fullscreen mode

Those copies may become inconsistent.

In a controlled form, the current value lives in React state:

const [email, setEmail] = useState("");
Enter fullscreen mode Exit fullscreen mode

The input displays that state:

value={email}
Enter fullscreen mode Exit fullscreen mode

Validation reads that state:

validateEmail(email);
Enter fullscreen mode Exit fullscreen mode

Submission sends that state:

await registerUser({ email });
Enter fullscreen mode Exit fullscreen mode
              React state
             /     |      \
            /      |       \
       Input   Validation  Submission
Enter fullscreen mode Exit fullscreen mode

Setting up the React example

Create a React TypeScript project using Vite:

npm create vite@latest react-form-demo \
  -- --template react-ts

cd react-form-demo

npm install

npm run dev
Enter fullscreen mode Exit fullscreen mode

Replace the contents of src/App.tsx.


Complete controlled registration form

import {
  FormEvent,
  useState,
} from "react";

interface RegistrationValues {
  username: string;
  email: string;
  password: string;
}

interface RegistrationErrors {
  username?: string;
  email?: string;
  password?: string;
  root?: string;
}

const initialValues: RegistrationValues = {
  username: "",
  email: "",
  password: "",
};

function validateRegistration(
  values: RegistrationValues
): RegistrationErrors {
  const errors: RegistrationErrors = {};

  const usernamePattern =
    /^[A-Za-z0-9_]+$/;

  const emailPattern =
    /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  if (!values.username.trim()) {
    errors.username =
      "Username is required.";
  } else if (
    values.username.trim().length < 3
  ) {
    errors.username =
      "Username must contain at least 3 characters.";
  } else if (
    !usernamePattern.test(
      values.username.trim()
    )
  ) {
    errors.username =
      "Use only letters, numbers, and underscores.";
  }

  if (!values.email.trim()) {
    errors.email = "Email is required.";
  } else if (
    !emailPattern.test(
      values.email.trim()
    )
  ) {
    errors.email =
      "Enter a valid email address.";
  }

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

  return errors;
}

async function registerUser(
  values: RegistrationValues
): Promise<void> {
  await new Promise((resolve) => {
    setTimeout(resolve, 1000);
  });

  if (
    values.email.toLowerCase() ===
    "existing@example.com"
  ) {
    throw new Error(
      "An account with this email already exists."
    );
  }

  console.log(
    "Submitted registration:",
    values
  );
}

export default function App() {
  const [values, setValues] =
    useState<RegistrationValues>(
      initialValues
    );

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

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

  const [isSuccess, setIsSuccess] =
    useState(false);

  function handleChange(
    event: React.ChangeEvent<HTMLInputElement>
  ) {
    const { name, value } = event.target;

    setValues((currentValues) => ({
      ...currentValues,
      [name]: value,
    }));

    setErrors((currentErrors) => ({
      ...currentErrors,
      [name]: undefined,
      root: undefined,
    }));

    setIsSuccess(false);
  }

  async function handleSubmit(
    event: FormEvent<HTMLFormElement>
  ) {
    event.preventDefault();

    setIsSuccess(false);

    const validationErrors =
      validateRegistration(values);

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

    setErrors({});
    setIsSubmitting(true);

    try {
      await registerUser({
        username:
          values.username.trim(),

        email:
          values.email
            .trim()
            .toLowerCase(),

        password:
          values.password,
      });

      setValues(initialValues);
      setIsSuccess(true);
    } catch (error) {
      const message =
        error instanceof Error
          ? error.message
          : "Registration failed.";

      if (
        message.includes(
          "email already exists"
        )
      ) {
        setErrors({
          email: message,
        });
      } else {
        setErrors({
          root: message,
        });
      }
    } finally {
      setIsSubmitting(false);
    }
  }

  console.log(
    "RegistrationForm rendered"
  );

  return (
    <main className="page">
      <form
        className="form"
        onSubmit={handleSubmit}
        noValidate
      >
        <h1>Create an account</h1>

        <div className="field">
          <label htmlFor="username">
            Username
          </label>

          <input
            id="username"
            name="username"
            type="text"
            value={values.username}
            onChange={handleChange}
            disabled={isSubmitting}
            aria-invalid={Boolean(
              errors.username
            )}
            aria-describedby={
              errors.username
                ? "username-error"
                : undefined
            }
          />

          {errors.username && (
            <p
              id="username-error"
              className="error"
            >
              {errors.username}
            </p>
          )}
        </div>

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

          <input
            id="email"
            name="email"
            type="email"
            value={values.email}
            onChange={handleChange}
            disabled={isSubmitting}
            aria-invalid={Boolean(
              errors.email
            )}
            aria-describedby={
              errors.email
                ? "email-error"
                : undefined
            }
          />

          {errors.email && (
            <p
              id="email-error"
              className="error"
            >
              {errors.email}
            </p>
          )}
        </div>

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

          <input
            id="password"
            name="password"
            type="password"
            value={values.password}
            onChange={handleChange}
            disabled={isSubmitting}
            aria-invalid={Boolean(
              errors.password
            )}
            aria-describedby={
              errors.password
                ? "password-error"
                : undefined
            }
          />

          {errors.password && (
            <p
              id="password-error"
              className="error"
            >
              {errors.password}
            </p>
          )}
        </div>

        {errors.root && (
          <p
            className="error"
            role="alert"
          >
            {errors.root}
          </p>
        )}

        {isSuccess && (
          <p
            className="success"
            role="status"
          >
            Registration successful.
          </p>
        )}

        <button
          type="submit"
          disabled={isSubmitting}
        >
          {isSubmitting
            ? "Creating account..."
            : "Register"}
        </button>
      </form>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add this to src/index.css:

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family:
    Inter,
    Arial,
    sans-serif;
  background: #f5f5f5;
}

button,
input {
  font: inherit;
}

.page {
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.form {
  width: min(100%, 480px);
  display: grid;
  gap: 18px;
  padding: 28px;
  background: white;
  border-radius: 12px;
}

.field {
  display: grid;
  gap: 6px;
}

input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #777;
  border-radius: 6px;
}

input[aria-invalid="true"] {
  border-color: #b91c1c;
}

button {
  padding: 11px 16px;
  border: 0;
  border-radius: 6px;
  cursor: pointer;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.65;
}

.error {
  margin: 0;
  color: #b91c1c;
  font-size: 14px;
}

.success {
  margin: 0;
  color: #15803d;
}
Enter fullscreen mode Exit fullscreen mode

Understanding the form state

The component has four main states.

const [values, setValues] =
  useState(initialValues);

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

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

const [isSuccess, setIsSuccess] =
  useState(false);
Enter fullscreen mode Exit fullscreen mode

They represent different concerns.

values
→ What the user entered

errors
→ What is invalid

isSubmitting
→ Whether the request is running

isSuccess
→ Whether the request succeeded
Enter fullscreen mode Exit fullscreen mode

Why use one values object?

We could write:

const [username, setUsername] =
  useState("");

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

const [password, setPassword] =
  useState("");
Enter fullscreen mode Exit fullscreen mode

That works for small forms.

Instead, we use:

const [values, setValues] = useState({
  username: "",
  email: "",
  password: "",
});
Enter fullscreen mode Exit fullscreen mode

This makes it easier to:

  • Pass all values to validation
  • Submit the complete object
  • Reset all fields
  • Create one shared change handler

Understanding the shared handleChange

function handleChange(
  event: React.ChangeEvent<HTMLInputElement>
) {
  const { name, value } = event.target;

  setValues((currentValues) => ({
    ...currentValues,
    [name]: value,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Suppose the email input changes.

<input
  name="email"
  onChange={handleChange}
/>
Enter fullscreen mode Exit fullscreen mode

The event contains:

name = "email"
value = "karthik@example.com"
Enter fullscreen mode Exit fullscreen mode

This:

[name]: value
Enter fullscreen mode Exit fullscreen mode

becomes:

email: "karthik@example.com"
Enter fullscreen mode Exit fullscreen mode

The previous state might be:

{
  username: "karthik",
  email: "",
  password: "Password123"
}
Enter fullscreen mode Exit fullscreen mode

After the update:

{
  username: "karthik",
  email: "karthik@example.com",
  password: "Password123"
}
Enter fullscreen mode Exit fullscreen mode

The spread operator preserves the other fields.

{
  ...currentValues,
  [name]: value
}
Enter fullscreen mode Exit fullscreen mode

Without the spread:

setValues({
  [name]: value,
});
Enter fullscreen mode Exit fullscreen mode

the other values would be removed.


Why must the input have a name?

<input
  name="email"
  value={values.email}
  onChange={handleChange}
/>
Enter fullscreen mode Exit fullscreen mode

The handler uses:

event.target.name
Enter fullscreen mode Exit fullscreen mode

to decide which property to update.

The names must match the object keys.

input name="email"
          ↓
values.email
Enter fullscreen mode Exit fullscreen mode
input name="username"
          ↓
values.username
Enter fullscreen mode Exit fullscreen mode

Every keystroke render flow

Suppose the component currently contains:

email = "kar"
Enter fullscreen mode Exit fullscreen mode

The user types:

t
Enter fullscreen mode Exit fullscreen mode

The flow is:

1. Browser detects input change

2. React calls handleChange()

3. event.target.value is "kart"

4. setValues() requests state update

5. React runs App() again

6. values.email is now "kart"

7. JSX is recalculated

8. React compares previous and next output

9. React updates the input's DOM value
Enter fullscreen mode Exit fullscreen mode

The component function runs again, but React does not necessarily rebuild every real DOM node.

It calculates the next UI description and updates what changed.


Observing renders

The example contains:

console.log(
  "RegistrationForm rendered"
);
Enter fullscreen mode Exit fullscreen mode

Open the browser console and type:

karthik@example.com
Enter fullscreen mode Exit fullscreen mode

You will see the message repeatedly.

That is expected for controlled inputs because each keystroke updates state.

React's current documentation explicitly warns that controlled inputs set state on every keystroke and can become slow when that state causes a large surrounding tree to re-render. It recommends isolating form state or using deferred updates where appropriate.

For a three-field form, this is normally not a problem.

For a large form embedded inside an expensive page, it can matter.


Controlled form render diagram

                         ┌──────────────────┐
                         │ React component  │
                         │      state       │
                         └────────┬─────────┘
                                  │
                                  │ value={values.email}
                                  ▼
                         ┌──────────────────┐
                         │   Email input    │
                         └────────┬─────────┘
                                  │
                                  │ User types
                                  ▼
                         ┌──────────────────┐
                         │    onChange      │
                         └────────┬─────────┘
                                  │
                                  │ setValues(...)
                                  ▼
                         ┌──────────────────┐
                         │ Component runs   │
                         │      again       │
                         └────────┬─────────┘
                                  │
                                  └─────── loop
Enter fullscreen mode Exit fullscreen mode

Why React is better than manual DOM manipulation

In Vanilla JavaScript:

if (error) {
  errorElement.textContent = error;
  input.setAttribute(
    "aria-invalid",
    "true"
  );
} else {
  errorElement.textContent = "";
  input.setAttribute(
    "aria-invalid",
    "false"
  );
}
Enter fullscreen mode Exit fullscreen mode

In React:

<input
  aria-invalid={Boolean(error)}
/>

{error && (
  <p className="error">
    {error}
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

We describe the UI for the current state.

If error exists
→ render error paragraph
→ set aria-invalid to true

If error does not exist
→ do not render paragraph
→ set aria-invalid to false
Enter fullscreen mode Exit fullscreen mode

The UI follows the data.


Advantages of controlled React forms

  • React state is the source of truth
  • UI is declarative
  • Values are immediately available
  • Conditional fields are easier to express
  • Inputs can depend on other values
  • Validation can use current state
  • Resetting can be done through state
  • Components can receive values through props
  • Testing state-driven UI is generally more predictable
  • TypeScript can describe the form structure

Disadvantages of controlled React forms

  • Every keystroke updates state
  • Component functions run again after updates
  • Considerable boilerplate
  • Manual error state is still required
  • Manual touched and dirty tracking may still be required
  • Large forms can become difficult to optimize
  • Custom components need careful value and event wiring
  • Developers can accidentally create controlled/uncontrolled warnings

Common beginner mistake: missing onChange

<input value={email} />
Enter fullscreen mode Exit fullscreen mode

The input becomes effectively read-only because React always forces its value to equal email, but nothing updates email.

Correct:

<input
  value={email}
  onChange={(event) => {
    setEmail(event.target.value);
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Common beginner mistake: using undefined

An input may start uncontrolled:

const [email, setEmail] =
  useState<string | undefined>(
    undefined
  );
Enter fullscreen mode Exit fullscreen mode

Later it becomes controlled:

setEmail("karthik@example.com");
Enter fullscreen mode Exit fullscreen mode

React may warn that an uncontrolled input became controlled.

For text inputs, use a consistent string initial value:

const [email, setEmail] =
  useState("");
Enter fullscreen mode Exit fullscreen mode

Common beginner mistake: mutating state

Bad:

values.email =
  event.target.value;

setValues(values);
Enter fullscreen mode Exit fullscreen mode

This mutates the existing object.

Better:

setValues((currentValues) => ({
  ...currentValues,
  email: event.target.value,
}));
Enter fullscreen mode Exit fullscreen mode

React state should be treated as immutable.


Common beginner mistake: one handler for incompatible input types

This works for text inputs:

const { name, value } =
  event.target;
Enter fullscreen mode Exit fullscreen mode

But a checkbox needs:

event.target.checked
Enter fullscreen mode Exit fullscreen mode

A file input needs:

event.target.files
Enter fullscreen mode Exit fullscreen mode

A robust handler must understand the input type.

function handleChange(
  event: React.ChangeEvent<HTMLInputElement>
) {
  const {
    name,
    type,
    value,
    checked,
    files,
  } = event.target;

  let nextValue:
    | string
    | boolean
    | FileList
    | null;

  if (type === "checkbox") {
    nextValue = checked;
  } else if (type === "file") {
    nextValue = files;
  } else {
    nextValue = value;
  }

  setValues((currentValues) => ({
    ...currentValues,
    [name]: nextValue,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Even this becomes more complicated when checkbox groups represent arrays.


TypeScript best practice

Define an explicit type for the form values.

interface RegistrationValues {
  username: string;
  email: string;
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Then type the initial object:

const initialValues:
  RegistrationValues = {
    username: "",
    email: "",
    password: "",
  };
Enter fullscreen mode Exit fullscreen mode

This protects against mistakes such as:

setValues({
  username: "",
  email: "",
  // password accidentally missing
});
Enter fullscreen mode Exit fullscreen mode

Interview question

What is a controlled component?

A controlled form component is an input whose current value is provided by React state and whose changes update that state through an event handler.

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

<input
  value={email}
  onChange={(event) => {
    setEmail(event.target.value);
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Why this evolved

Controlled React forms replaced scattered DOM manipulation with predictable state-driven UI. However, React only provided the building blocks. Developers still had to manually manage values, errors, touched fields, dirty fields, validation, submission, and performance. As forms grew, this created a new kind of complexity.


Stage 7: Large React Forms and State Explosion

A three-field controlled form is clean enough.

A ten-field form begins to reveal the cost.

Imagine tracking:

10 values
10 field errors
10 touched states
10 dirty states
1 loading state
1 success state
1 root error
Enter fullscreen mode Exit fullscreen mode

Depending on the design, that can produce more than 40 logical state values.


Approach 1: Separate state for every field

A beginner may begin like this:

const [firstName, setFirstName] =
  useState("");

const [lastName, setLastName] =
  useState("");

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

const [phone, setPhone] =
  useState("");

const [country, setCountry] =
  useState("");

const [city, setCity] =
  useState("");

const [jobTitle, setJobTitle] =
  useState("");

const [joiningDate, setJoiningDate] =
  useState("");

const [bio, setBio] =
  useState("");

const [acceptTerms, setAcceptTerms] =
  useState(false);
Enter fullscreen mode Exit fullscreen mode

Then errors:

const [firstNameError, setFirstNameError] =
  useState("");

const [lastNameError, setLastNameError] =
  useState("");

const [emailError, setEmailError] =
  useState("");

const [phoneError, setPhoneError] =
  useState("");
Enter fullscreen mode Exit fullscreen mode

Then touched state:

const [emailTouched, setEmailTouched] =
  useState(false);

const [phoneTouched, setPhoneTouched] =
  useState(false);
Enter fullscreen mode Exit fullscreen mode

This quickly becomes unmanageable.


Approach 2: Group related state

A better approach uses objects:

const [values, setValues] =
  useState(initialValues);

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

const [touched, setTouched] =
  useState({});

const [dirtyFields, setDirtyFields] =
  useState({});
Enter fullscreen mode Exit fullscreen mode

This reduces the number of useState calls.

However, we must still write the logic that updates every object correctly.


Understanding touched

A field is usually considered touched after the user focuses and leaves it.

function handleBlur(
  event: React.FocusEvent<
    HTMLInputElement
  >
) {
  const { name } = event.target;

  setTouched((currentTouched) => ({
    ...currentTouched,
    [name]: true,
  }));
}
Enter fullscreen mode Exit fullscreen mode

Then we might show an error only when the field is touched:

{touched.email &&
  errors.email && (
    <p>{errors.email}</p>
  )}
Enter fullscreen mode Exit fullscreen mode

This avoids displaying errors before the user has interacted.


Understanding dirty

A field is dirty when its current value differs from its initial value.

Initial email:
""

Current email:
"karthik@example.com"

Dirty:
true
Enter fullscreen mode Exit fullscreen mode

A simple update:

setDirtyFields(
  (currentDirtyFields) => ({
    ...currentDirtyFields,
    [name]:
      value !==
      initialValues[
        name as keyof FormValues
      ],
  })
);
Enter fullscreen mode Exit fullscreen mode

The entire form is dirty if at least one field is dirty.

const isDirty =
  Object.values(
    dirtyFields
  ).some(Boolean);
Enter fullscreen mode Exit fullscreen mode

Why touched and dirty are different

Suppose the user:

  1. Clicks the email input.
  2. Types nothing.
  3. Clicks outside.

Result:

touched = true
dirty = false
Enter fullscreen mode Exit fullscreen mode

Now suppose the user:

  1. Types an email.
  2. Deletes it back to the original empty value.

Depending on the form system's definition:

touched = true
dirty = false
Enter fullscreen mode Exit fullscreen mode

The field was modified, but its current value equals its default value.

Touched answers:

Has the user interacted with the field?
Enter fullscreen mode Exit fullscreen mode

Dirty answers:

Does the current value differ from the default?
Enter fullscreen mode Exit fullscreen mode

Complete large controlled form

The example below manages:

  • Ten values
  • Field errors
  • Touched fields
  • Dirty fields
  • Loading state
  • Success state
  • Disabled state
  • Validation on blur
  • Validation on submit
  • Form reset
import {
  ChangeEvent,
  FocusEvent,
  FormEvent,
  useState,
} from "react";

interface EmployeeFormValues {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  country: string;
  city: string;
  jobTitle: string;
  joiningDate: string;
  bio: string;
  acceptTerms: boolean;
}

type EmployeeFieldName =
  keyof EmployeeFormValues;

type EmployeeErrors =
  Partial<
    Record<EmployeeFieldName, string>
  > & {
    root?: string;
  };

type EmployeeTouched =
  Partial<
    Record<EmployeeFieldName, boolean>
  >;

type EmployeeDirtyFields =
  Partial<
    Record<EmployeeFieldName, boolean>
  >;

const initialValues:
  EmployeeFormValues = {
    firstName: "",
    lastName: "",
    email: "",
    phone: "",
    country: "",
    city: "",
    jobTitle: "",
    joiningDate: "",
    bio: "",
    acceptTerms: false,
  };

const emailPattern =
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const phonePattern =
  /^[0-9+\-\s]{8,15}$/;

function validateField(
  name: EmployeeFieldName,
  value:
    EmployeeFormValues[EmployeeFieldName]
): string | undefined {
  switch (name) {
    case "firstName": {
      if (
        typeof value !== "string" ||
        !value.trim()
      ) {
        return "First name is required.";
      }

      if (value.trim().length < 2) {
        return "First name is too short.";
      }

      return undefined;
    }

    case "lastName": {
      if (
        typeof value !== "string" ||
        !value.trim()
      ) {
        return "Last name is required.";
      }

      return undefined;
    }

    case "email": {
      if (
        typeof value !== "string" ||
        !value.trim()
      ) {
        return "Email is required.";
      }

      if (
        !emailPattern.test(value.trim())
      ) {
        return "Enter a valid email address.";
      }

      return undefined;
    }

    case "phone": {
      if (
        typeof value !== "string" ||
        !value.trim()
      ) {
        return "Phone number is required.";
      }

      if (
        !phonePattern.test(value.trim())
      ) {
        return "Enter a valid phone number.";
      }

      return undefined;
    }

    case "country": {
      if (!value) {
        return "Country is required.";
      }

      return undefined;
    }

    case "city": {
      if (
        typeof value !== "string" ||
        !value.trim()
      ) {
        return "City is required.";
      }

      return undefined;
    }

    case "jobTitle": {
      if (
        typeof value !== "string" ||
        !value.trim()
      ) {
        return "Job title is required.";
      }

      return undefined;
    }

    case "joiningDate": {
      if (!value) {
        return "Joining date is required.";
      }

      return undefined;
    }

    case "bio": {
      if (
        typeof value === "string" &&
        value.trim().length > 300
      ) {
        return "Biography cannot exceed 300 characters.";
      }

      return undefined;
    }

    case "acceptTerms": {
      if (value !== true) {
        return "You must accept the terms.";
      }

      return undefined;
    }

    default: {
      return undefined;
    }
  }
}

function validateForm(
  values: EmployeeFormValues
): EmployeeErrors {
  const errors: EmployeeErrors = {};

  (
    Object.keys(
      values
    ) as EmployeeFieldName[]
  ).forEach((name) => {
    const error = validateField(
      name,
      values[name]
    );

    if (error) {
      errors[name] = error;
    }
  });

  return errors;
}

async function submitEmployee(
  values: EmployeeFormValues
): Promise<void> {
  await new Promise((resolve) => {
    setTimeout(resolve, 1200);
  });

  console.log(
    "Submitted employee:",
    values
  );
}

export default function App() {
  const [values, setValues] =
    useState<EmployeeFormValues>(
      initialValues
    );

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

  const [touched, setTouched] =
    useState<EmployeeTouched>({});

  const [
    dirtyFields,
    setDirtyFields,
  ] =
    useState<EmployeeDirtyFields>(
      {}
    );

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

  const [isSuccess, setIsSuccess] =
    useState(false);

  const isDirty =
    Object.values(
      dirtyFields
    ).some(Boolean);

  function updateField<
    TName extends EmployeeFieldName,
  >(
    name: TName,
    value: EmployeeFormValues[TName]
  ) {
    setValues((currentValues) => ({
      ...currentValues,
      [name]: value,
    }));

    setDirtyFields(
      (currentDirtyFields) => ({
        ...currentDirtyFields,
        [name]:
          value !== initialValues[name],
      })
    );

    setErrors((currentErrors) => ({
      ...currentErrors,
      [name]: undefined,
      root: undefined,
    }));

    setIsSuccess(false);
  }

  function handleTextChange(
    event: ChangeEvent<
      HTMLInputElement |
      HTMLTextAreaElement |
      HTMLSelectElement
    >
  ) {
    const name =
      event.target
        .name as EmployeeFieldName;

    updateField(
      name,
      event.target.value
    );
  }

  function handleCheckboxChange(
    event: ChangeEvent<HTMLInputElement>
  ) {
    updateField(
      "acceptTerms",
      event.target.checked
    );
  }

  function handleBlur(
    event: FocusEvent<
      HTMLInputElement |
      HTMLTextAreaElement |
      HTMLSelectElement
    >
  ) {
    const name =
      event.target
        .name as EmployeeFieldName;

    setTouched(
      (currentTouched) => ({
        ...currentTouched,
        [name]: true,
      })
    );

    const fieldError =
      validateField(
        name,
        values[name]
      );

    setErrors(
      (currentErrors) => ({
        ...currentErrors,
        [name]: fieldError,
      })
    );
  }

  function resetForm() {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setDirtyFields({});
    setIsSuccess(false);
  }

  async function handleSubmit(
    event: FormEvent<HTMLFormElement>
  ) {
    event.preventDefault();

    setIsSuccess(false);

    const nextErrors =
      validateForm(values);

    const allTouched =
      Object.fromEntries(
        Object.keys(values).map(
          (name) => [
            name,
            true,
          ]
        )
      ) as EmployeeTouched;

    setTouched(allTouched);
    setErrors(nextErrors);

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

    setIsSubmitting(true);

    try {
      await submitEmployee({
        ...values,
        firstName:
          values.firstName.trim(),

        lastName:
          values.lastName.trim(),

        email:
          values.email
            .trim()
            .toLowerCase(),

        phone:
          values.phone.trim(),

        city:
          values.city.trim(),

        jobTitle:
          values.jobTitle.trim(),

        bio:
          values.bio.trim(),
      });

      resetForm();
      setIsSuccess(true);
    } catch (error) {
      setErrors({
        root:
          error instanceof Error
            ? error.message
            : "Submission failed.",
      });
    } finally {
      setIsSubmitting(false);
    }
  }

  function getError(
    name: EmployeeFieldName
  ) {
    if (!touched[name]) {
      return undefined;
    }

    return errors[name];
  }

  const firstNameError =
    getError("firstName");

  const lastNameError =
    getError("lastName");

  const emailError =
    getError("email");

  const phoneError =
    getError("phone");

  const countryError =
    getError("country");

  const cityError =
    getError("city");

  const jobTitleError =
    getError("jobTitle");

  const joiningDateError =
    getError("joiningDate");

  const bioError =
    getError("bio");

  const acceptTermsError =
    getError("acceptTerms");

  return (
    <main className="page">
      <form
        className="form"
        onSubmit={handleSubmit}
        noValidate
      >
        <header>
          <h1>Employee onboarding</h1>

          <p>
            Form status:{" "}
            {isDirty
              ? "Unsaved changes"
              : "No changes"}
          </p>
        </header>

        <div className="row">
          <div className="field">
            <label htmlFor="firstName">
              First name
            </label>

            <input
              id="firstName"
              name="firstName"
              value={values.firstName}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                firstNameError
              )}
              aria-describedby={
                firstNameError
                  ? "firstName-error"
                  : undefined
              }
            />

            {firstNameError && (
              <p
                id="firstName-error"
                className="error"
              >
                {firstNameError}
              </p>
            )}
          </div>

          <div className="field">
            <label htmlFor="lastName">
              Last name
            </label>

            <input
              id="lastName"
              name="lastName"
              value={values.lastName}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                lastNameError
              )}
              aria-describedby={
                lastNameError
                  ? "lastName-error"
                  : undefined
              }
            />

            {lastNameError && (
              <p
                id="lastName-error"
                className="error"
              >
                {lastNameError}
              </p>
            )}
          </div>
        </div>

        <div className="row">
          <div className="field">
            <label htmlFor="email">
              Email
            </label>

            <input
              id="email"
              name="email"
              type="email"
              value={values.email}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                emailError
              )}
              aria-describedby={
                emailError
                  ? "email-error"
                  : undefined
              }
            />

            {emailError && (
              <p
                id="email-error"
                className="error"
              >
                {emailError}
              </p>
            )}
          </div>

          <div className="field">
            <label htmlFor="phone">
              Phone
            </label>

            <input
              id="phone"
              name="phone"
              type="tel"
              value={values.phone}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                phoneError
              )}
              aria-describedby={
                phoneError
                  ? "phone-error"
                  : undefined
              }
            />

            {phoneError && (
              <p
                id="phone-error"
                className="error"
              >
                {phoneError}
              </p>
            )}
          </div>
        </div>

        <div className="row">
          <div className="field">
            <label htmlFor="country">
              Country
            </label>

            <select
              id="country"
              name="country"
              value={values.country}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                countryError
              )}
              aria-describedby={
                countryError
                  ? "country-error"
                  : undefined
              }
            >
              <option value="">
                Select a country
              </option>

              <option value="india">
                India
              </option>

              <option value="usa">
                United States
              </option>

              <option value="uk">
                United Kingdom
              </option>
            </select>

            {countryError && (
              <p
                id="country-error"
                className="error"
              >
                {countryError}
              </p>
            )}
          </div>

          <div className="field">
            <label htmlFor="city">
              City
            </label>

            <input
              id="city"
              name="city"
              value={values.city}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                cityError
              )}
              aria-describedby={
                cityError
                  ? "city-error"
                  : undefined
              }
            />

            {cityError && (
              <p
                id="city-error"
                className="error"
              >
                {cityError}
              </p>
            )}
          </div>
        </div>

        <div className="row">
          <div className="field">
            <label htmlFor="jobTitle">
              Job title
            </label>

            <input
              id="jobTitle"
              name="jobTitle"
              value={values.jobTitle}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                jobTitleError
              )}
              aria-describedby={
                jobTitleError
                  ? "jobTitle-error"
                  : undefined
              }
            />

            {jobTitleError && (
              <p
                id="jobTitle-error"
                className="error"
              >
                {jobTitleError}
              </p>
            )}
          </div>

          <div className="field">
            <label htmlFor="joiningDate">
              Joining date
            </label>

            <input
              id="joiningDate"
              name="joiningDate"
              type="date"
              value={values.joiningDate}
              onChange={handleTextChange}
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                joiningDateError
              )}
              aria-describedby={
                joiningDateError
                  ? "joiningDate-error"
                  : undefined
              }
            />

            {joiningDateError && (
              <p
                id="joiningDate-error"
                className="error"
              >
                {joiningDateError}
              </p>
            )}
          </div>
        </div>

        <div className="field">
          <label htmlFor="bio">
            Short biography
          </label>

          <textarea
            id="bio"
            name="bio"
            rows={4}
            value={values.bio}
            onChange={handleTextChange}
            onBlur={handleBlur}
            disabled={isSubmitting}
            aria-invalid={Boolean(
              bioError
            )}
            aria-describedby={
              bioError
                ? "bio-error"
                : "bio-hint"
            }
          />

          <p id="bio-hint">
            {values.bio.length}/300
            characters
          </p>

          {bioError && (
            <p
              id="bio-error"
              className="error"
            >
              {bioError}
            </p>
          )}
        </div>

        <div className="field">
          <label>
            <input
              name="acceptTerms"
              type="checkbox"
              checked={
                values.acceptTerms
              }
              onChange={
                handleCheckboxChange
              }
              onBlur={handleBlur}
              disabled={isSubmitting}
              aria-invalid={Boolean(
                acceptTermsError
              )}
              aria-describedby={
                acceptTermsError
                  ? "acceptTerms-error"
                  : undefined
              }
            />

            I accept the employment terms
          </label>

          {acceptTermsError && (
            <p
              id="acceptTerms-error"
              className="error"
            >
              {acceptTermsError}
            </p>
          )}
        </div>

        {errors.root && (
          <p
            className="error"
            role="alert"
          >
            {errors.root}
          </p>
        )}

        {isSuccess && (
          <p
            className="success"
            role="status"
          >
            Employee details submitted.
          </p>
        )}

        <div className="actions">
          <button
            type="button"
            onClick={resetForm}
            disabled={
              isSubmitting ||
              !isDirty
            }
          >
            Reset
          </button>

          <button
            type="submit"
            disabled={isSubmitting}
          >
            {isSubmitting
              ? "Submitting..."
              : "Submit"}
          </button>
        </div>
      </form>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add these styles to the earlier CSS:

.form {
  width: min(100%, 760px);
}

.row {
  display: grid;
  grid-template-columns:
    repeat(2, minmax(0, 1fr));
  gap: 16px;
}

textarea,
select {
  width: 100%;
  padding: 10px 12px;
  font: inherit;
}

.actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

@media (max-width: 640px) {
  .row {
    grid-template-columns: 1fr;
  }
}
Enter fullscreen mode Exit fullscreen mode

What did we have to build manually?

Although the form is written in React, we manually implemented:

Values
Errors
Touched fields
Dirty fields
Overall dirty status
Validation on blur
Validation on submit
Error visibility rules
Reset behavior
Loading state
Success state
Checkbox handling
Input normalization
Root-level errors
Enter fullscreen mode Exit fullscreen mode

React handled rendering.

React did not design the form architecture for us.


Approximate boilerplate

Depending on formatting and component extraction, a ten-field controlled form can easily require hundreds of lines.

The problem is not merely the number of lines.

The deeper problem is that much of the code is infrastructure rather than business-specific UI.

For example:

setTouched((currentTouched) => ({
  ...currentTouched,
  [name]: true,
}));
Enter fullscreen mode Exit fullscreen mode
setDirtyFields(
  (currentDirtyFields) => ({
    ...currentDirtyFields,
    [name]:
      value !== initialValues[name],
  })
);
Enter fullscreen mode Exit fullscreen mode
setErrors((currentErrors) => ({
  ...currentErrors,
  [name]: undefined,
}));
Enter fullscreen mode Exit fullscreen mode

Every React project began recreating similar logic.


State explosion diagram

Form
├── values
│   ├── firstName
│   ├── lastName
│   ├── email
│   ├── phone
│   └── ...
│
├── errors
│   ├── firstName
│   ├── lastName
│   ├── email
│   └── ...
│
├── touched
│   ├── firstName
│   ├── lastName
│   └── ...
│
├── dirtyFields
│   ├── firstName
│   ├── lastName
│   └── ...
│
├── isDirty
├── isValid
├── isSubmitting
├── isSuccess
└── rootError
Enter fullscreen mode Exit fullscreen mode

Performance problems

Every controlled input change updates React state.

User types
    ↓
setValues()
    ↓
Parent form component runs again
    ↓
All JSX inside the component is recalculated
Enter fullscreen mode Exit fullscreen mode

React is efficient, and rerendering does not mean every DOM element is recreated.

However, a large component may still perform:

  • Validation calculations
  • Derived-state calculations
  • Rendering of many child components
  • Expensive formatting
  • Filtering or searching
  • Conditional layout work

React's documentation recommends moving controlled input state into a smaller component when a large surrounding tree does not need to update on every keystroke.


Common performance mistake

Consider:

export default function CheckoutPage() {
  const [email, setEmail] =
    useState("");

  return (
    <>
      <LargeNavigation />
      <ProductList />
      <Recommendations />
      <CheckoutSummary />

      <input
        value={email}
        onChange={(event) => {
          setEmail(
            event.target.value
          );
        }}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every email update causes CheckoutPage to run again.

A better structure may isolate the form:

export default function CheckoutPage() {
  return (
    <>
      <LargeNavigation />
      <ProductList />
      <Recommendations />
      <CheckoutSummary />
      <CheckoutForm />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the form state lives inside:

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

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

Only the relevant subtree needs to rerender.


Derived state can become inconsistent

Suppose we store both:

const [values, setValues] =
  useState(initialValues);

const [isDirty, setIsDirty] =
  useState(false);
Enter fullscreen mode Exit fullscreen mode

Now two pieces of state describe related information.

A bug may occur:

setValues(initialValues);

// Forgot:
setIsDirty(false);
Enter fullscreen mode Exit fullscreen mode

The form is empty but still says:

Unsaved changes
Enter fullscreen mode Exit fullscreen mode

Whenever possible, derive state:

const isDirty =
  Object.keys(values).some((key) => {
    const name =
      key as keyof EmployeeFormValues;

    return (
      values[name] !==
      initialValues[name]
    );
  });
Enter fullscreen mode Exit fullscreen mode

However, repeated derivation can also become expensive or complicated for nested values.

Form libraries attempt to manage these relationships consistently.


Advantages of a manual large React form

  • Complete control
  • No form-library dependency
  • Business logic is explicit
  • Easy to customize unusual behavior
  • Useful for learning React state deeply
  • Appropriate for small or highly specialized forms

Disadvantages of a manual large React form

  • Significant boilerplate
  • Manual field registration
  • Manual touched-state tracking
  • Manual dirty-state tracking
  • Manual validation timing
  • Manual nested updates
  • More opportunities for inconsistent state
  • Performance optimization becomes the application's responsibility
  • Dynamic arrays require considerable custom code
  • Repeating the architecture across many forms wastes time

Senior engineer tip

Do not install a form library merely because a form exists.

A small search form may need only:

const [query, setQuery] =
  useState("");
Enter fullscreen mode Exit fullscreen mode

A library becomes valuable when it removes meaningful complexity.

Good candidates include forms with:

  • Many fields
  • Validation schemas
  • Dynamic arrays
  • Nested values
  • Conditional sections
  • Server errors
  • Reusable input components
  • Complex submission states
  • Performance-sensitive pages

Interview question

What is the difference between touched and dirty?

Touched indicates that the user interacted with a field, commonly by focusing and then leaving it.

Dirty indicates that the current value differs from the field's initial or default value.

A field can be touched without being dirty.


Why this evolved

React made form UI predictable, but developers repeatedly rebuilt the same state-management infrastructure: values, errors, touched fields, dirty fields, validation, resets, and submission handling. Form libraries emerged to standardize and reuse that work.


Stage 8: The Rise of Form Libraries

A form library is not mainly an input-design library.

It generally does not decide:

  • Your colors
  • Your border radius
  • Your labels
  • Your layout
  • Your design system

A form library manages form behavior and state.

It commonly provides:

Value management
Error management
Touched tracking
Dirty tracking
Validation
Submission handling
Field registration
Reset behavior
Nested values
Dynamic arrays
Server-error APIs
Performance optimizations
Enter fullscreen mode Exit fullscreen mode

What form libraries abstract

Without a library:

const [values, setValues] =
  useState(initialValues);

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

const [touched, setTouched] =
  useState({});

const [dirtyFields, setDirtyFields] =
  useState({});

const [isSubmitting, setIsSubmitting] =
  useState(false);
Enter fullscreen mode Exit fullscreen mode

With a library, the API may resemble:

const {
  register,
  handleSubmit,
  formState: {
    errors,
    isDirty,
    dirtyFields,
    touchedFields,
    isSubmitting,
  },
} = useForm();
Enter fullscreen mode Exit fullscreen mode

The complexity has not disappeared.

The library now manages it.


Important distinction: abstraction versus magic

A form library does not make validation, state, or events disappear.

It provides reusable implementations.

Your form
    ↓
Form-library API
    ↓
Internal form state
    ↓
Field subscriptions or React updates
    ↓
Rendered interface
Enter fullscreen mode Exit fullscreen mode

Understanding the manual approach helps you debug the abstraction.


Major React form-library families

This section compares:

  • Formik
  • Final Form
  • React Final Form
  • React Hook Form
  • TanStack Form

They solve similar problems but make different architectural choices.


Formik

Formik became one of the most recognizable React form libraries during the late 2010s. Its repository and community resources show active production use and tutorials from 2017 onward.

Formik describes its purpose as handling repetitive form concerns such as values, errors, visited fields, validation, and submission.

Its mental model feels familiar to developers who already understand controlled React state.


Formik's basic approach

Formik stores form state
        ↓
Formik gives values and handlers
        ↓
Inputs receive value/onChange/onBlur
        ↓
State updates
        ↓
Relevant React UI rerenders
Enter fullscreen mode Exit fullscreen mode

Common Formik state includes:

values
errors
touched
dirty
isSubmitting
Enter fullscreen mode Exit fullscreen mode

Install Formik

npm install formik
Enter fullscreen mode Exit fullscreen mode

Complete Formik example

import {
  Form,
  Formik,
} from "formik";

interface RegistrationValues {
  username: string;
  email: string;
  password: string;
}

interface RegistrationErrors {
  username?: string;
  email?: string;
  password?: string;
}

const initialValues:
  RegistrationValues = {
    username: "",
    email: "",
    password: "",
  };

function validate(
  values: RegistrationValues
): RegistrationErrors {
  const errors:
    RegistrationErrors = {};

  if (!values.username.trim()) {
    errors.username =
      "Username is required.";
  } else if (
    values.username.trim().length < 3
  ) {
    errors.username =
      "Username must contain at least 3 characters.";
  }

  if (!values.email.trim()) {
    errors.email =
      "Email is required.";
  } else if (
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
      values.email.trim()
    )
  ) {
    errors.email =
      "Enter a valid email address.";
  }

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

  return errors;
}

export default function FormikExample() {
  return (
    <Formik<RegistrationValues>
      initialValues={initialValues}
      validate={validate}
      onSubmit={async (
        values,
        helpers
      ) => {
        try {
          await new Promise(
            (resolve) => {
              setTimeout(
                resolve,
                1000
              );
            }
          );

          if (
            values.email ===
            "existing@example.com"
          ) {
            helpers.setFieldError(
              "email",
              "Email already exists."
            );

            return;
          }

          console.log(values);

          helpers.resetForm();
        } finally {
          helpers.setSubmitting(
            false
          );
        }
      }}
    >
      {({
        values,
        errors,
        touched,
        dirty,
        isSubmitting,
        handleChange,
        handleBlur,
      }) => (
        <Form
          className="form"
          noValidate
        >
          <h1>Formik registration</h1>

          <p>
            {dirty
              ? "Unsaved changes"
              : "No changes"}
          </p>

          <div className="field">
            <label htmlFor="username">
              Username
            </label>

            <input
              id="username"
              name="username"
              value={
                values.username
              }
              onChange={
                handleChange
              }
              onBlur={handleBlur}
              aria-invalid={Boolean(
                touched.username &&
                  errors.username
              )}
              aria-describedby={
                touched.username &&
                errors.username
                  ? "username-error"
                  : undefined
              }
            />

            {touched.username &&
              errors.username && (
                <p
                  id="username-error"
                  className="error"
                >
                  {errors.username}
                </p>
              )}
          </div>

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

            <input
              id="email"
              name="email"
              type="email"
              value={values.email}
              onChange={
                handleChange
              }
              onBlur={handleBlur}
              aria-invalid={Boolean(
                touched.email &&
                  errors.email
              )}
              aria-describedby={
                touched.email &&
                errors.email
                  ? "email-error"
                  : undefined
              }
            />

            {touched.email &&
              errors.email && (
                <p
                  id="email-error"
                  className="error"
                >
                  {errors.email}
                </p>
              )}
          </div>

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

            <input
              id="password"
              name="password"
              type="password"
              value={
                values.password
              }
              onChange={
                handleChange
              }
              onBlur={handleBlur}
              aria-invalid={Boolean(
                touched.password &&
                  errors.password
              )}
              aria-describedby={
                touched.password &&
                errors.password
                  ? "password-error"
                  : undefined
              }
            />

            {touched.password &&
              errors.password && (
                <p
                  id="password-error"
                  className="error"
                >
                  {errors.password}
                </p>
              )}
          </div>

          <button
            type="submit"
            disabled={isSubmitting}
          >
            {isSubmitting
              ? "Registering..."
              : "Register"}
          </button>
        </Form>
      )}
    </Formik>
  );
}
Enter fullscreen mode Exit fullscreen mode

What Formik removed

We no longer manually created:

const [values, setValues] =
  useState(...);

const [errors, setErrors] =
  useState(...);

const [touched, setTouched] =
  useState(...);

const [isSubmitting, setIsSubmitting] =
  useState(...);
Enter fullscreen mode Exit fullscreen mode

Formik provided:

values
errors
touched
dirty
isSubmitting
handleChange
handleBlur
setFieldError
resetForm
Enter fullscreen mode Exit fullscreen mode

Formik advantages

  • Familiar controlled-form mental model
  • Mature API
  • Strong historical ecosystem
  • Handles values, errors, touched, and submission
  • Integrates with schema validators
  • Supports nested values and arrays
  • Straightforward for developers familiar with React state

Formik disadvantages

  • Can produce broad rerenders in large forms
  • Render-prop code may become deeply nested
  • Controlled-state architecture can require performance work
  • Large forms may need field-level optimization
  • Some modern teams prefer hook-based or subscription-based alternatives

Formik does provide APIs such as hooks and optimized fields, but developers must still understand how form-state changes affect rendering.


Final Form

Final Form is a framework-independent form-state engine.

It is not limited to React.

Its central architectural idea is subscriptions.

Form state changes
        ↓
Which consumers subscribed
to that exact state?
        ↓
Notify only those consumers
Enter fullscreen mode Exit fullscreen mode

A field can subscribe to:

value
error
touched
dirty
active
validating
Enter fullscreen mode Exit fullscreen mode

Instead of forcing every form consumer to react to every change.


React Final Form

React Final Form is the React integration for Final Form.

The official documentation describes React Final Form as a thin React wrapper over the subscription-based Final Form engine, using an Observer-style architecture so components can rerender based on the state they subscribe to.

Its early public issue activity dates to late 2017, placing it in the same broader generation as Formik's initial rise.

Do not confuse the names:

Final Form
→ Core form-state engine

React Final Form
→ React wrapper around Final Form
Enter fullscreen mode Exit fullscreen mode

Install React Final Form

npm install final-form react-final-form
Enter fullscreen mode Exit fullscreen mode

Complete React Final Form example

import {
  Field,
  Form,
} from "react-final-form";

interface RegistrationValues {
  username: string;
  email: string;
  password: string;
}

interface RegistrationErrors {
  username?: string;
  email?: string;
  password?: string;
}

function validate(
  values: RegistrationValues
): RegistrationErrors {
  const errors:
    RegistrationErrors = {};

  if (!values.username) {
    errors.username =
      "Username is required.";
  } else if (
    values.username.length < 3
  ) {
    errors.username =
      "Username must contain at least 3 characters.";
  }

  if (!values.email) {
    errors.email =
      "Email is required.";
  } else if (
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
      values.email
    )
  ) {
    errors.email =
      "Enter a valid email address.";
  }

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

  return errors;
}

export default function ReactFinalFormExample() {
  return (
    <Form<RegistrationValues>
      initialValues={{
        username: "",
        email: "",
        password: "",
      }}
      validate={validate}
      onSubmit={async (
        values,
        form
      ) => {
        await new Promise(
          (resolve) => {
            setTimeout(resolve, 1000);
          }
        );

        if (
          values.email ===
          "existing@example.com"
        ) {
          return {
            email:
              "Email already exists.",
          };
        }

        console.log(values);

        form.restart();

        return undefined;
      }}
      subscription={{
        submitting: true,
        pristine: true,
      }}
      render={({
        handleSubmit,
        submitting,
        pristine,
      }) => (
        <form
          className="form"
          onSubmit={handleSubmit}
          noValidate
        >
          <h1>
            React Final Form
          </h1>

          <Field<string>
            name="username"
            subscription={{
              value: true,
              error: true,
              touched: true,
            }}
          >
            {({
              input,
              meta,
            }) => {
              const showError =
                meta.touched &&
                meta.error;

              return (
                <div className="field">
                  <label htmlFor="username">
                    Username
                  </label>

                  <input
                    {...input}
                    id="username"
                    aria-invalid={Boolean(
                      showError
                    )}
                    aria-describedby={
                      showError
                        ? "username-error"
                        : undefined
                    }
                  />

                  {showError && (
                    <p
                      id="username-error"
                      className="error"
                    >
                      {meta.error}
                    </p>
                  )}
                </div>
              );
            }}
          </Field>

          <Field<string>
            name="email"
            subscription={{
              value: true,
              error: true,
              touched: true,
            }}
          >
            {({
              input,
              meta,
            }) => {
              const showError =
                meta.touched &&
                meta.error;

              return (
                <div className="field">
                  <label htmlFor="email">
                    Email
                  </label>

                  <input
                    {...input}
                    id="email"
                    type="email"
                    aria-invalid={Boolean(
                      showError
                    )}
                    aria-describedby={
                      showError
                        ? "email-error"
                        : undefined
                    }
                  />

                  {showError && (
                    <p
                      id="email-error"
                      className="error"
                    >
                      {meta.error}
                    </p>
                  )}
                </div>
              );
            }}
          </Field>

          <Field<string>
            name="password"
            subscription={{
              value: true,
              error: true,
              touched: true,
            }}
          >
            {({
              input,
              meta,
            }) => {
              const showError =
                meta.touched &&
                meta.error;

              return (
                <div className="field">
                  <label htmlFor="password">
                    Password
                  </label>

                  <input
                    {...input}
                    id="password"
                    type="password"
                    aria-invalid={Boolean(
                      showError
                    )}
                    aria-describedby={
                      showError
                        ? "password-error"
                        : undefined
                    }
                  />

                  {showError && (
                    <p
                      id="password-error"
                      className="error"
                    >
                      {meta.error}
                    </p>
                  )}
                </div>
              );
            }}
          </Field>

          <button
            type="submit"
            disabled={
              submitting ||
              pristine
            }
          >
            {submitting
              ? "Registering..."
              : "Register"}
          </button>
        </form>
      )}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Understanding subscriptions

The form subscribes to:

subscription={{
  submitting: true,
  pristine: true,
}}
Enter fullscreen mode Exit fullscreen mode

That means the outer form renderer mainly cares about:

submitting
pristine
Enter fullscreen mode Exit fullscreen mode

The username field subscribes to:

subscription={{
  value: true,
  error: true,
  touched: true,
}}
Enter fullscreen mode Exit fullscreen mode

It does not need every property from the complete form.

React Final Form's <Field> registers itself with the form, subscribes to field state, and receives field callbacks and metadata. The documentation also allows developers to customize exactly which state properties cause field updates.


React Final Form advantages

  • Fine-grained subscriptions
  • Framework-independent core
  • Strong control over rerenders
  • Rich field metadata
  • Good support for complex state
  • Can avoid broad whole-form updates
  • Clear separation between form engine and React integration

React Final Form disadvantages

  • Subscription concepts add a learning curve
  • Render props can create visual nesting
  • Incorrect subscriptions may hide needed updates
  • The API may feel more explicit than simpler hook-based approaches
  • Teams must understand both Final Form and its React adapter

React Hook Form

React Hook Form became prominent during the React Hooks era.

Public repository issues show active use by mid-2019, shortly after Hooks became part of mainstream React development.

Its major architectural difference is that it embraces uncontrolled native inputs while still supporting controlled components when required.

Instead of storing every keystroke in parent React state, React Hook Form can register native inputs and access their values through the DOM and refs.

We will explore its internals deeply in the next part.


Install React Hook Form

npm install react-hook-form
Enter fullscreen mode Exit fullscreen mode

Complete React Hook Form preview

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

interface RegistrationValues {
  username: string;
  email: string;
  password: string;
}

export default function ReactHookFormExample() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: {
      errors,
      isDirty,
      isSubmitting,
    },
  } =
    useForm<RegistrationValues>({
      defaultValues: {
        username: "",
        email: "",
        password: "",
      },
    });

  const onSubmit:
    SubmitHandler<
      RegistrationValues
    > = async (values) => {
      await new Promise(
        (resolve) => {
          setTimeout(resolve, 1000);
        }
      );

      if (
        values.email ===
        "existing@example.com"
      ) {
        setError("email", {
          type: "server",
          message:
            "Email already exists.",
        });

        return;
      }

      console.log(values);

      reset();
    };

  return (
    <form
      className="form"
      onSubmit={
        handleSubmit(onSubmit)
      }
      noValidate
    >
      <h1>
        React Hook Form
      </h1>

      <p>
        {isDirty
          ? "Unsaved changes"
          : "No changes"}
      </p>

      <div className="field">
        <label htmlFor="username">
          Username
        </label>

        <input
          id="username"
          aria-invalid={Boolean(
            errors.username
          )}
          aria-describedby={
            errors.username
              ? "username-error"
              : undefined
          }
          {...register(
            "username",
            {
              required:
                "Username is required.",

              minLength: {
                value: 3,
                message:
                  "Username must contain at least 3 characters.",
              },
            }
          )}
        />

        {errors.username && (
          <p
            id="username-error"
            className="error"
          >
            {
              errors.username
                .message
            }
          </p>
        )}
      </div>

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

        <input
          id="email"
          type="email"
          aria-invalid={Boolean(
            errors.email
          )}
          aria-describedby={
            errors.email
              ? "email-error"
              : undefined
          }
          {...register("email", {
            required:
              "Email is required.",

            pattern: {
              value:
                /^[^\s@]+@[^\s@]+\.[^\s@]+$/,

              message:
                "Enter a valid email address.",
            },
          })}
        />

        {errors.email && (
          <p
            id="email-error"
            className="error"
          >
            {errors.email.message}
          </p>
        )}
      </div>

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

        <input
          id="password"
          type="password"
          aria-invalid={Boolean(
            errors.password
          )}
          aria-describedby={
            errors.password
              ? "password-error"
              : undefined
          }
          {...register(
            "password",
            {
              required:
                "Password is required.",

              minLength: {
                value: 8,
                message:
                  "Password must contain at least 8 characters.",
              },
            }
          )}
        />

        {errors.password && (
          <p
            id="password-error"
            className="error"
          >
            {
              errors.password
                .message
            }
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
      >
        {isSubmitting
          ? "Registering..."
          : "Register"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice what is missing:

value={values.email}

onChange={handleChange}

onBlur={handleBlur}
Enter fullscreen mode Exit fullscreen mode

Instead, we write:

{...register("email")}
Enter fullscreen mode Exit fullscreen mode

That is our first hint that React Hook Form uses a different approach.


React Hook Form advantages

  • Less form boilerplate
  • Uncontrolled-input support
  • Fewer broad React state updates
  • Hook-based API
  • Strong TypeScript support
  • Field-level and schema validation
  • Server-error APIs
  • Dynamic-field support
  • Works with controlled components through adapters

React Hook Form disadvantages

  • Uncontrolled architecture can initially feel unfamiliar
  • register() spreads several props at once
  • Custom controlled components require Controller or manual integration
  • Developers must understand default values and registration
  • Watching many fields can introduce additional rerenders
  • Some behavior depends on correct field naming

TanStack Form

TanStack Form is part of the newer generation of form libraries.

Its documentation describes it as framework-agnostic, headless, strongly TypeScript-oriented, and designed for large production forms. It provides adapters for several frontend frameworks, including React.

Unlike React Hook Form's uncontrolled-first philosophy, TanStack Form explicitly embraces controlled form state. Its documentation argues that controlled values can improve predictability, testing, conditional logic, debugging, and non-DOM usage.

This demonstrates an important engineering lesson:

There is no architecture with only advantages.

React Hook Form and TanStack Form make different trade-offs.


Install TanStack Form

npm install @tanstack/react-form
Enter fullscreen mode Exit fullscreen mode

Complete TanStack Form example

import {
  useForm,
} from "@tanstack/react-form";

export default function TanStackFormExample() {
  const form = useForm({
    defaultValues: {
      username: "",
      email: "",
      password: "",
    },

    onSubmit: async ({
      value,
    }) => {
      await new Promise(
        (resolve) => {
          setTimeout(resolve, 1000);
        }
      );

      console.log(value);
    },
  });

  return (
    <form
      className="form"
      noValidate
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();

        void form.handleSubmit();
      }}
    >
      <h1>TanStack Form</h1>

      <form.Field
        name="username"
        validators={{
          onBlur: ({ value }) => {
            if (!value.trim()) {
              return "Username is required.";
            }

            if (
              value.trim().length < 3
            ) {
              return "Username must contain at least 3 characters.";
            }

            return undefined;
          },
        }}
      >
        {(field) => {
          const error =
            field.state.meta
              .isTouched
              ? field.state.meta
                  .errors[0]
              : undefined;

          return (
            <div className="field">
              <label htmlFor="username">
                Username
              </label>

              <input
                id="username"
                name={field.name}
                value={
                  field.state.value
                }
                onBlur={
                  field.handleBlur
                }
                onChange={(event) => {
                  field.handleChange(
                    event.target.value
                  );
                }}
                aria-invalid={Boolean(
                  error
                )}
                aria-describedby={
                  error
                    ? "username-error"
                    : undefined
                }
              />

              {error && (
                <p
                  id="username-error"
                  className="error"
                >
                  {String(error)}
                </p>
              )}
            </div>
          );
        }}
      </form.Field>

      <form.Field
        name="email"
        validators={{
          onBlur: ({ value }) => {
            if (!value.trim()) {
              return "Email is required.";
            }

            if (
              !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
                value.trim()
              )
            ) {
              return "Enter a valid email address.";
            }

            return undefined;
          },
        }}
      >
        {(field) => {
          const error =
            field.state.meta
              .isTouched
              ? field.state.meta
                  .errors[0]
              : undefined;

          return (
            <div className="field">
              <label htmlFor="email">
                Email
              </label>

              <input
                id="email"
                name={field.name}
                type="email"
                value={
                  field.state.value
                }
                onBlur={
                  field.handleBlur
                }
                onChange={(event) => {
                  field.handleChange(
                    event.target.value
                  );
                }}
                aria-invalid={Boolean(
                  error
                )}
                aria-describedby={
                  error
                    ? "email-error"
                    : undefined
                }
              />

              {error && (
                <p
                  id="email-error"
                  className="error"
                >
                  {String(error)}
                </p>
              )}
            </div>
          );
        }}
      </form.Field>

      <form.Field
        name="password"
        validators={{
          onBlur: ({ value }) => {
            if (!value) {
              return "Password is required.";
            }

            if (value.length < 8) {
              return "Password must contain at least 8 characters.";
            }

            return undefined;
          },
        }}
      >
        {(field) => {
          const error =
            field.state.meta
              .isTouched
              ? field.state.meta
                  .errors[0]
              : undefined;

          return (
            <div className="field">
              <label htmlFor="password">
                Password
              </label>

              <input
                id="password"
                name={field.name}
                type="password"
                value={
                  field.state.value
                }
                onBlur={
                  field.handleBlur
                }
                onChange={(event) => {
                  field.handleChange(
                    event.target.value
                  );
                }}
                aria-invalid={Boolean(
                  error
                )}
                aria-describedby={
                  error
                    ? "password-error"
                    : undefined
                }
              />

              {error && (
                <p
                  id="password-error"
                  className="error"
                >
                  {String(error)}
                </p>
              )}
            </div>
          );
        }}
      </form.Field>

      <form.Subscribe
        selector={(state) => [
          state.canSubmit,
          state.isSubmitting,
          state.isDirty,
        ]}
      >
        {([
          canSubmit,
          isSubmitting,
          isDirty,
        ]) => (
          <>
            <p>
              {isDirty
                ? "Unsaved changes"
                : "No changes"}
            </p>

            <button
              type="submit"
              disabled={
                !canSubmit ||
                isSubmitting
              }
            >
              {isSubmitting
                ? "Registering..."
                : "Register"}
            </button>
          </>
        )}
      </form.Subscribe>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

TanStack Form supports field-level and form-level validation, multiple validation timings, and synchronous or asynchronous validation.


TanStack Form advantages

  • Strong type inference
  • Framework-agnostic core
  • Headless architecture
  • Fine-grained subscriptions
  • Controlled and predictable state
  • Flexible validation timing
  • Designed for composition and large applications
  • Useful when forms are central to the product

TanStack Form disadvantages

  • More concepts to learn
  • APIs can feel verbose for small forms
  • Controlled architecture still involves value-driven updates
  • A newer ecosystem than long-established alternatives
  • May be unnecessary for simple login or search forms

Form-library comparison

Library Main architecture Input style Main strength Main trade-off
Formik Central React form state Mostly controlled Familiar, mature mental model Can create broad rerenders and boilerplate
Final Form Framework-independent subscription engine Adapter-dependent Fine-grained subscriptions More architecture to understand
React Final Form React wrapper over Final Form Usually controlled through field props Precise rerender subscriptions Render-prop and subscription complexity
React Hook Form Registration, refs, subscriptions Uncontrolled-first Low boilerplate and fewer React updates Different mental model; controlled components need adapters
TanStack Form Typed controlled form engine with subscriptions Controlled Type safety, composition, framework support More concepts and verbosity

When did each approach become popular?

Rather than treating popularity as a single release date, it is better to think in ecosystem generations.

Early to mid-2010s
└── Hand-written controlled React forms

Late 2010s
├── Formik becomes a common default
└── Final Form / React Final Form introduce
    subscription-focused alternatives

React Hooks era, from 2019 onward
└── React Hook Form grows around hooks,
    registration, refs, and uncontrolled inputs

2020s
└── TypeScript-first and framework-agnostic
    systems such as TanStack Form expand
    the design space
Enter fullscreen mode Exit fullscreen mode

Repository activity and official resources support Formik and React Final Form adoption beginning around 2017, while React Hook Form had an active public user base by 2019.

Popularity does not automatically determine the best choice for your project.


Which library should you choose?

Choose plain React state when:

  • The form has one to three fields
  • Validation is simple
  • There are no dynamic arrays
  • You do not need elaborate touched or dirty state
  • Adding a dependency would provide little value

Examples:

Search box
Newsletter subscription
Simple filter
Two-field login
Enter fullscreen mode Exit fullscreen mode

Consider Formik when:

  • The team already uses Formik
  • Existing components are built around it
  • Developers understand its conventions
  • Migrating would provide little measurable benefit
  • Controlled state matches the product requirements

Do not rewrite stable forms merely because another library is newer.


Consider React Final Form when:

  • Fine-grained subscriptions are valuable
  • The team understands subscription-based state
  • Existing architecture uses Final Form
  • Form state must be managed independently of React
  • Precise control over updates matters

Consider React Hook Form when:

  • You want low boilerplate
  • Forms mostly use native inputs
  • Performance is important
  • You need Zod or another schema resolver
  • The project uses TypeScript
  • You need dynamic field arrays
  • You want clear server-error integration

Consider TanStack Form when:

  • Forms are central to the application
  • Type safety is a major priority
  • The project needs complex validation timing
  • Framework-independent architecture matters
  • The team values controlled predictability
  • The team accepts a larger learning investment

Common beginner mistake: choosing from benchmark charts alone

A form library may advertise fewer rerenders or smaller bundles.

Those factors matter, but they are not the complete decision.

Also evaluate:

Team knowledge
Documentation quality
Accessibility patterns
Validation integration
Design-system compatibility
Testing strategy
Migration cost
Long-term maintenance
Complex-field support
Server-error handling
Enter fullscreen mode Exit fullscreen mode

The fastest library in a small artificial benchmark is not automatically the safest architectural choice for your production application.


Common beginner mistake: installing multiple form libraries

Avoid using:

Formik in one new form
React Hook Form in another
TanStack Form in another
Custom useState in every remaining form
Enter fullscreen mode Exit fullscreen mode

without a clear reason.

This creates:

  • Inconsistent error handling
  • Different validation timing
  • Different reusable components
  • More dependencies
  • More onboarding difficulty
  • More testing patterns

A team should usually establish a default approach while allowing justified exceptions.


Common beginner mistake: expecting the library to provide security

Form libraries improve frontend behavior.

They do not replace:

  • Server-side validation
  • Authorization
  • Rate limiting
  • CSRF defenses where required
  • Secure cookies
  • Password hashing
  • Database constraints
  • File scanning
  • Output encoding

Even a perfectly validated React form can be bypassed by calling the API directly.


Senior engineer tip: separate concerns

A form usually contains at least four layers.

1. Presentation
   Labels, inputs, errors, layout

2. Form state
   Values, touched, dirty, submitting

3. Validation
   Required fields, formats, business rules

4. Data submission
   API requests, server errors, retries
Enter fullscreen mode Exit fullscreen mode

Avoid combining everything in one component.

A healthier direction looks like:

RegistrationForm
├── registrationSchema
├── registerUser API function
├── reusable Input component
└── form-library integration
Enter fullscreen mode Exit fullscreen mode

Later in this series, we will implement that structure using:

React Hook Form
+
Zod
+
TypeScript
+
Express backend
Enter fullscreen mode Exit fullscreen mode

Interview questions

1. Why do React form libraries exist?

They reduce repeated code for:

  • Values
  • Errors
  • Touched state
  • Dirty state
  • Validation
  • Submission
  • Resetting
  • Dynamic fields
  • Nested data

They also provide consistent APIs and performance strategies.


2. What is the difference between Formik and React Hook Form?

Formik commonly follows a controlled, React-state-oriented model.

React Hook Form is uncontrolled-first and registers inputs using refs and event handlers, reducing the need to store every keystroke in parent React state.


3. What is the difference between Final Form and React Final Form?

Final Form is the framework-independent form-state engine.

React Final Form is the React integration built on top of Final Form.


4. What is a subscription-based form library?

A subscription-based library allows fields or components to listen only to specific parts of form state.

For example, an email error component may subscribe to:

email.error
email.touched
Enter fullscreen mode Exit fullscreen mode

It does not need to update when the password value changes.


5. Should every React form use a library?

No.

A small form may be simpler with useState or native form APIs.

Use a library when it removes more complexity than it introduces.


Performance comparison

Manual controlled form

Email changes
     ↓
Parent state updates
     ↓
Parent component runs
     ↓
All child JSX is recalculated
Enter fullscreen mode Exit fullscreen mode

Subscription-based form

Email changes
     ↓
Form store updates email
     ↓
Email subscribers are notified
     ↓
Only relevant consumers update
Enter fullscreen mode Exit fullscreen mode

Uncontrolled-first form

Email changes
     ↓
Browser input stores value
     ↓
Form library observes/registers field
     ↓
React rerender happens only when
subscribed form state must change
Enter fullscreen mode Exit fullscreen mode

These diagrams are simplified.

Real performance depends on:

  • Component boundaries
  • Subscriptions
  • Validation mode
  • Watched fields
  • Controlled third-party inputs
  • Parent rerenders
  • Derived calculations
  • Application size

Part 2 summary

We began with a manual form that scaled poorly.

DOM queries
Validation functions
Error elements
Event listeners
Loading flags
Nested data
Dynamic fields
Enter fullscreen mode Exit fullscreen mode

Then React introduced declarative, state-driven UI.

User input
    ↓
Update React state
    ↓
Component runs
    ↓
UI is derived from state
Enter fullscreen mode Exit fullscreen mode

Controlled forms gave us predictability, but large forms required us to manually manage:

values
errors
touched
dirtyFields
isDirty
isValid
isSubmitting
reset
validation
Enter fullscreen mode Exit fullscreen mode

Form libraries emerged because teams were rebuilding the same infrastructure repeatedly.

Manual controlled forms
          ↓
Repeated state-management patterns
          ↓
Formik and Final Form generation
          ↓
Hooks and uncontrolled-first approaches
          ↓
Modern typed form engines
Enter fullscreen mode Exit fullscreen mode

The major lesson is:

A form library does not remove form complexity. It organizes and abstracts that complexity.

In the next part, we will examine React Hook Form in detail.

We will cover:

Stage 9
React Hook Form philosophy and architecture

Stage 10
useForm, register, handleSubmit, watch,
setValue, getValues, reset, trigger,
Controller, formState, and more

Stage 11
Yup, Zod, Valibot, Vest, and manual validation

Stage 12
React Hook Form with Zod and TypeScript
Enter fullscreen mode Exit fullscreen mode

Top comments (0)