DEV Community

Karthik Reddy
Karthik Reddy

Posted on

The Evolution of Web Forms — Part 1

The Evolution of Web Forms Part-1 — From Plain HTML to AJAX

Modern React forms can feel unnecessarily complicated when you first encounter tools such as React Hook Form, Zod, resolvers, controlled inputs, refs, formState, and server-error handling.

Why do we need all of that?

Why not simply read the value from an input and send it to the server?

To understand why modern form libraries exist, we need to understand the problems developers faced before those libraries were created.

In this series, we will evolve the same idea step by step:

Plain HTML
    ↓
Native HTML validation
    ↓
JavaScript validation
    ↓
AJAX submission
    ↓
React controlled forms
    ↓
Form libraries
    ↓
React Hook Form
    ↓
React Hook Form + Zod
    ↓
Production form architecture
Enter fullscreen mode Exit fullscreen mode

This first part covers the first four stages:

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

By the end, you will understand how forms worked before React and why each new approach became necessary.


Stage 1: Plain HTML Forms

Before React, AJAX, or even large amounts of client-side JavaScript, browsers already knew how to submit forms.

HTML forms are not just visual containers. They are a built-in browser mechanism for collecting data and sending an HTTP request.

A basic registration form

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />

    <title>Registration Form</title>
  </head>

  <body>
    <h1>Create an account</h1>

    <form action="/register" method="POST">
      <div>
        <label for="username">Username</label>

        <input
          id="username"
          name="username"
          type="text"
        />
      </div>

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

        <input
          id="email"
          name="email"
          type="email"
        />
      </div>

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

        <input
          id="password"
          name="password"
          type="password"
        />
      </div>

      <button type="submit">Register</button>
    </form>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

There is no JavaScript in this example.

The browser handles the entire submission process.


Understanding the <form> element

The <form> element groups fields that belong to one submission.

<form action="/register" method="POST">
  <!-- Form fields -->
</form>
Enter fullscreen mode Exit fullscreen mode

The two most important attributes are:

  • action
  • method

The action attribute

The action tells the browser where the form data should be sent.

<form action="/register">
Enter fullscreen mode Exit fullscreen mode

In this example, the browser sends the request to:

/register
Enter fullscreen mode Exit fullscreen mode

If the website is running at:

https://example.com
Enter fullscreen mode Exit fullscreen mode

the complete request URL becomes:

https://example.com/register
Enter fullscreen mode Exit fullscreen mode

The action may also contain a complete URL:

<form action="https://api.example.com/register">
Enter fullscreen mode Exit fullscreen mode

The method attribute

The method tells the browser which HTTP method to use.

The two methods traditionally supported by HTML forms are:

GET
POST
Enter fullscreen mode Exit fullscreen mode

GET form

<form action="/search" method="GET">
  <input name="query" />
  <button type="submit">Search</button>
</form>
Enter fullscreen mode Exit fullscreen mode

If the user enters:

react forms
Enter fullscreen mode Exit fullscreen mode

the browser navigates to something similar to:

/search?query=react+forms
Enter fullscreen mode Exit fullscreen mode

The data becomes part of the URL.

GET is commonly used for:

  • Search forms
  • Filters
  • Pagination
  • Read-only operations
  • Shareable URLs

Sensitive information should not be sent through query parameters.

Never send passwords using a GET form.


POST form

<form action="/register" method="POST">
Enter fullscreen mode Exit fullscreen mode

POST sends the data in the HTTP request body instead of placing it in the URL.

POST is commonly used for:

  • Registration
  • Login
  • Creating resources
  • Uploading files
  • Submitting private information

Using POST does not automatically encrypt the data. HTTPS is still required.


The importance of the name attribute

Consider this input:

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

The id identifies the input inside the HTML document.

The name identifies the field during form submission.

When the browser submits the form, it creates key-value pairs using the name attributes.

username=Karthik
email=karthik@example.com
password=secret123
Enter fullscreen mode Exit fullscreen mode

Without a name, the browser usually does not include the input in the submitted form data.

For example:

<input id="email" type="email" />
Enter fullscreen mode Exit fullscreen mode

This field can appear on the screen, but its value will not be submitted as part of the normal browser form request.

This distinction is important:

id      → identifies the HTML element
name    → identifies the submitted form field
value   → contains the user's input
Enter fullscreen mode Exit fullscreen mode

Understanding <label>

<label for="email">Email</label>

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

The label's for value matches the input's id.

label for="email"
        ↓
input id="email"
Enter fullscreen mode Exit fullscreen mode

This association provides two important benefits.

Mouse and touch usability

Clicking the label focuses the input.

Accessibility

Screen readers can identify the input as an email field with the label “Email.”

Avoid building forms using placeholder text as the only label.

Bad:

<input
  type="email"
  placeholder="Enter your email"
/>
Enter fullscreen mode Exit fullscreen mode

Better:

<label for="email">Email</label>

<input
  id="email"
  name="email"
  type="email"
  placeholder="karthik@example.com"
/>
Enter fullscreen mode Exit fullscreen mode

A placeholder is an example or hint. It should not replace a label.


Understanding the submit button

<button type="submit">Register</button>
Enter fullscreen mode Exit fullscreen mode

A button with type="submit" tells the browser to submit its associated form.

Inside a form, a button may behave as a submit button even when its type is omitted.

<button>Register</button>
Enter fullscreen mode Exit fullscreen mode

However, relying on the default can create accidental submissions.

It is better to be explicit:

<button type="submit">Register</button>
Enter fullscreen mode Exit fullscreen mode

For buttons that should not submit the form, use:

<button type="button">Show password</button>
Enter fullscreen mode Exit fullscreen mode

What happens after clicking Submit?

Suppose the user enters:

Username: karthik
Email: karthik@example.com
Password: secret123
Enter fullscreen mode Exit fullscreen mode

Then the user clicks Register.

The browser performs approximately the following steps:

User clicks Submit
        ↓
Browser finds the surrounding form
        ↓
Browser collects successful form controls
        ↓
Browser uses each field's name as the key
        ↓
Browser encodes the values
        ↓
Browser sends an HTTP request to the form action
        ↓
Server processes the request
        ↓
Server sends an HTTP response
        ↓
Browser replaces the current page with the response
Enter fullscreen mode Exit fullscreen mode

The request may look conceptually like this:

POST /register HTTP/1.1
Host: localhost:3000
Content-Type: application/x-www-form-urlencoded

username=karthik&email=karthik%40example.com&password=secret123
Enter fullscreen mode Exit fullscreen mode

The default encoding is commonly:

application/x-www-form-urlencoded
Enter fullscreen mode Exit fullscreen mode

This means form values are encoded into key-value pairs.


Why does the page refresh?

Traditional form submission is also a browser navigation.

The browser sends the request and expects the server to return the next page.

For example:

Registration page
      ↓ submit
POST /register
      ↓
Server creates the user
      ↓
Server returns HTML or redirects
      ↓
Browser displays the new page
Enter fullscreen mode Exit fullscreen mode

The server might redirect the user:

HTTP/1.1 302 Found
Location: /login
Enter fullscreen mode Exit fullscreen mode

The browser then requests /login and displays the login page.

This is sometimes called the traditional multi-page application model.

Each important interaction can result in a new page request.


Complete working example

Create the following structure:

plain-html-form/
├── public/
│   └── index.html
├── package.json
└── server.js
Enter fullscreen mode Exit fullscreen mode

public/index.html

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

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

    <title>Plain HTML Registration</title>

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

      form {
        display: flex;
        flex-direction: column;
        gap: 16px;
      }

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

      input {
        padding: 10px;
        font-size: 16px;
      }

      button {
        padding: 10px;
        cursor: pointer;
      }
    </style>
  </head>

  <body>
    <h1>Create an account</h1>

    <form action="/register" method="POST">
      <div class="field">
        <label for="username">Username</label>

        <input
          id="username"
          name="username"
          type="text"
        />
      </div>

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

        <input
          id="email"
          name="email"
          type="email"
        />
      </div>

      <div class="field">
        <label for="password">Password</label>

        <input
          id="password"
          name="password"
          type="password"
        />
      </div>

      <button type="submit">Register</button>
    </form>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

server.js

const express = require("express");
const path = require("path");

const app = express();
const PORT = 3000;

// Reads application/x-www-form-urlencoded request bodies.
app.use(express.urlencoded({ extended: false }));

app.use(express.static(path.join(__dirname, "public")));

app.post("/register", (request, response) => {
  const { username, email, password } = request.body;

  console.log("Received registration data:", {
    username,
    email,
    password,
  });

  response.send(`
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <title>Registration Successful</title>
      </head>

      <body>
        <h1>Registration successful</h1>
        <p>Welcome, ${username}.</p>
        <a href="/">Return to registration</a>
      </body>
    </html>
  `);
});

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Install and run

npm init -y
npm install express
node server.js
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

After submission, notice that:

  1. The browser sends a POST request.
  2. The server receives the form fields.
  3. The browser leaves the registration page.
  4. The browser displays the HTML returned by the server.

Advantages of plain HTML forms

  • Work without JavaScript
  • Built directly into browsers
  • Simple to understand
  • Good accessibility when correctly structured
  • Can function on slow devices
  • Support keyboard submission
  • Easy for search engines and assistive technology to understand
  • Useful as a progressive-enhancement foundation

Disadvantages of plain HTML forms

  • The page navigates or refreshes after submission
  • There is little control over the user experience
  • Validation depends mainly on the server
  • Error messages often require rendering another page
  • Preserving entered values after a server error requires additional work
  • Loading states are difficult to display without JavaScript
  • Complex interactions are difficult to implement

Common beginner mistakes

Forgetting name

<input id="email" type="email" />
Enter fullscreen mode Exit fullscreen mode

The field may not be included in the submitted request.

Using GET for passwords

<form method="GET">
Enter fullscreen mode Exit fullscreen mode

This can expose the password in URLs, browser history, analytics, logs, and server records.

Using placeholders instead of labels

Placeholder text disappears when the user starts typing and is not a proper replacement for a visible label.

Forgetting the button type

A button intended to toggle password visibility may accidentally submit the form.

<button type="button">Show password</button>
Enter fullscreen mode Exit fullscreen mode

Security note

A form is not secure merely because it uses method="POST".

Production forms should also consider:

  • HTTPS
  • Server-side validation
  • Password hashing
  • CSRF protection where applicable
  • Rate limiting
  • Input sanitization where appropriate
  • Secure cookies
  • Authentication and authorization
  • Protection against automated abuse

Never trust data only because it came from an HTML form.


Interview question

What is the difference between id and name in an input?

id identifies the HTML element and connects it to labels or accessibility attributes.

name determines the key used when the browser submits the form.

<input
  id="user-email"
  name="email"
/>
Enter fullscreen mode Exit fullscreen mode

The server receives:

email=<entered-value>
Enter fullscreen mode Exit fullscreen mode

It does not receive:

user-email=<entered-value>
Enter fullscreen mode Exit fullscreen mode

Why this evolved

Plain HTML forms could submit data reliably, but users could enter completely invalid information. Developers needed a way to catch basic mistakes before sending the request to the server. This led to native HTML validation.


Stage 2: Native HTML Validation

Imagine a registration form where the user submits:

Username:
Email: hello
Password: 12
Enter fullscreen mode Exit fullscreen mode

Without validation, the browser sends this data to the server.

The server then needs to reject it and return an error response.

This creates unnecessary work:

Invalid input
    ↓
Network request
    ↓
Server processing
    ↓
Error response
    ↓
Page rendered again
Enter fullscreen mode Exit fullscreen mode

Browsers introduced validation attributes that allow developers to describe basic input rules directly in HTML.


The required attribute

<input
  id="username"
  name="username"
  type="text"
  required
/>
Enter fullscreen mode Exit fullscreen mode

The browser prevents form submission when the field is empty.

<input required />
Enter fullscreen mode Exit fullscreen mode

This is a boolean attribute. It does not need:

required="true"
Enter fullscreen mode Exit fullscreen mode

Although that may work, the standard shorthand is simply:

required
Enter fullscreen mode Exit fullscreen mode

minlength and maxlength

<input
  id="username"
  name="username"
  type="text"
  minlength="3"
  maxlength="20"
/>
Enter fullscreen mode Exit fullscreen mode

This means the username should contain between 3 and 20 characters.

For passwords:

<input
  id="password"
  name="password"
  type="password"
  minlength="8"
  maxlength="72"
/>
Enter fullscreen mode Exit fullscreen mode

maxlength can also prevent the user from entering more characters.


The pattern attribute

The pattern attribute applies a regular-expression rule.

<input
  id="username"
  name="username"
  type="text"
  pattern="[A-Za-z0-9_]+"
/>
Enter fullscreen mode Exit fullscreen mode

This accepts:

karthik
karthik_2005
User123
Enter fullscreen mode Exit fullscreen mode

It rejects values containing spaces or unsupported symbols.

A more complete example:

<input
  id="username"
  name="username"
  type="text"
  required
  minlength="3"
  maxlength="20"
  pattern="[A-Za-z0-9_]+"
  title="Use only letters, numbers, and underscores"
/>
Enter fullscreen mode Exit fullscreen mode

The title provides additional guidance that some browsers may include in their validation message.

Do not use complicated regular expressions when a clearer validation strategy is available.


Email input

<input
  id="email"
  name="email"
  type="email"
  required
/>
Enter fullscreen mode Exit fullscreen mode

The browser checks whether the value resembles an email address.

It may reject:

hello
karthik@
@example.com
Enter fullscreen mode Exit fullscreen mode

However, this check is intentionally basic.

It cannot determine whether:

  • The domain exists
  • The mailbox exists
  • The user owns the email address
  • The email is already registered

Email verification and server-side uniqueness checks are still required.


Number input

<input
  id="age"
  name="age"
  type="number"
  min="18"
  max="100"
/>
Enter fullscreen mode Exit fullscreen mode

This field accepts numeric input and applies minimum and maximum rules.

Minimum: 18
Maximum: 100
Enter fullscreen mode Exit fullscreen mode

Do not assume type="number" is correct for every value containing digits.

Phone numbers, PIN codes, postal codes, Aadhaar-like identifiers, and credit-card numbers are identifiers, not mathematical quantities.

They may contain:

  • Leading zeroes
  • Spaces
  • Country prefixes
  • Formatting characters

Such values are often better represented using text inputs with appropriate input modes.

<input
  type="text"
  inputmode="numeric"
/>
Enter fullscreen mode Exit fullscreen mode

Date input

<input
  id="birthDate"
  name="birthDate"
  type="date"
  required
/>
Enter fullscreen mode Exit fullscreen mode

The browser may display a native date picker.

The interface can differ between:

  • Chrome
  • Firefox
  • Safari
  • Mobile browsers
  • Operating systems

The submitted value generally follows this shape:

YYYY-MM-DD
Enter fullscreen mode Exit fullscreen mode

For example:

2005-08-24
Enter fullscreen mode Exit fullscreen mode

URL input

<input
  id="portfolio"
  name="portfolio"
  type="url"
  placeholder="https://example.com"
/>
Enter fullscreen mode Exit fullscreen mode

The browser checks whether the value resembles a URL.

Depending on the browser, entering:

example.com
Enter fullscreen mode Exit fullscreen mode

may fail because the protocol is missing.

A complete value would be:

https://example.com
Enter fullscreen mode Exit fullscreen mode

Password input

<input
  id="password"
  name="password"
  type="password"
  required
  minlength="8"
/>
Enter fullscreen mode Exit fullscreen mode

type="password" hides the visible characters.

It does not:

  • Encrypt the password
  • Hash the password
  • Secure the network request
  • Protect the server database

HTTPS protects data in transit.

The server should hash passwords using an appropriate password-hashing algorithm before storage.


Complete native-validation example

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

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

    <title>HTML Validated Registration</title>

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

      form {
        display: flex;
        flex-direction: column;
        gap: 16px;
      }

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

      input {
        padding: 10px;
        font-size: 16px;
      }

      input:invalid:not(:placeholder-shown) {
        border: 2px solid #b91c1c;
      }

      input:valid:not(:placeholder-shown) {
        border: 2px solid #15803d;
      }

      button {
        padding: 10px;
        cursor: pointer;
      }

      .hint {
        color: #555;
        font-size: 14px;
      }
    </style>
  </head>

  <body>
    <h1>Create an account</h1>

    <form action="/register" method="POST">
      <div class="field">
        <label for="username">Username</label>

        <input
          id="username"
          name="username"
          type="text"
          required
          minlength="3"
          maxlength="20"
          pattern="[A-Za-z0-9_]+"
          placeholder="karthik_2005"
          title="Use 3–20 letters, numbers, or underscores"
        />

        <span class="hint">
          Use 3–20 letters, numbers, or underscores.
        </span>
      </div>

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

        <input
          id="email"
          name="email"
          type="email"
          required
          placeholder="karthik@example.com"
        />
      </div>

      <div class="field">
        <label for="age">Age</label>

        <input
          id="age"
          name="age"
          type="number"
          required
          min="18"
          max="100"
          placeholder="21"
        />
      </div>

      <div class="field">
        <label for="birthDate">Date of birth</label>

        <input
          id="birthDate"
          name="birthDate"
          type="date"
          required
        />
      </div>

      <div class="field">
        <label for="portfolio">Portfolio URL</label>

        <input
          id="portfolio"
          name="portfolio"
          type="url"
          placeholder="https://example.com"
        />
      </div>

      <div class="field">
        <label for="password">Password</label>

        <input
          id="password"
          name="password"
          type="password"
          required
          minlength="8"
          maxlength="72"
          placeholder="Minimum 8 characters"
        />
      </div>

      <button type="submit">Register</button>
    </form>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

What happens during native validation?

When the user submits the form:

User clicks Submit
        ↓
Browser checks built-in constraints
        ↓
Are all fields valid?
       / \
     No   Yes
     ↓     ↓
Show     Send HTTP
message  request
Enter fullscreen mode Exit fullscreen mode

When a field is invalid, the browser:

  1. Stops the form submission.
  2. Focuses an invalid field.
  3. Displays a browser-generated message.
  4. Does not send the request.

The exact message depends on the browser and operating-system language.

Examples may resemble:

Please fill out this field.
Please include an “@” in the email address.
Please lengthen this text to 8 characters or more.
Enter fullscreen mode Exit fullscreen mode

The browser Constraint Validation API

Native validation is not limited to HTML attributes.

JavaScript can inspect a field's validity:

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

console.log(emailInput.validity);
console.log(emailInput.validationMessage);
console.log(emailInput.checkValidity());
Enter fullscreen mode Exit fullscreen mode

You can also assign a custom native validation message:

const usernameInput =
  document.getElementById("username");

usernameInput.setCustomValidity(
  "This username is not available."
);
Enter fullscreen mode Exit fullscreen mode

To remove the custom error:

usernameInput.setCustomValidity("");
Enter fullscreen mode Exit fullscreen mode

This API became an important bridge between built-in browser validation and fully custom JavaScript validation.


Advantages of native HTML validation

  • Requires little code
  • Works without JavaScript
  • Prevents many invalid requests
  • Integrates with browser focus behavior
  • Supports keyboard users
  • Provides semantic information to browsers
  • Useful for simple forms
  • Can serve as a fallback even when JavaScript exists

Disadvantages of native HTML validation

  • Error-message design differs between browsers
  • Styling browser validation messages is limited
  • Messages may be difficult to customize consistently
  • Cross-field validation is difficult
  • Asynchronous validation is not handled
  • Cannot check whether an email already exists
  • Cannot easily validate complex business rules
  • Some rules are too complicated for HTML attributes
  • Browser behavior can vary

A cross-field validation problem

Consider these fields:

<input
  id="password"
  name="password"
  type="password"
/>

<input
  id="confirmPassword"
  name="confirmPassword"
  type="password"
/>
Enter fullscreen mode Exit fullscreen mode

HTML can validate the minimum length of each field.

However, HTML alone cannot easily express:

confirmPassword must equal password
Enter fullscreen mode Exit fullscreen mode

That validation depends on comparing two fields.

Similarly, HTML cannot independently determine:

startDate must be before endDate
Enter fullscreen mode Exit fullscreen mode

or:

at least one communication method must be selected
Enter fullscreen mode Exit fullscreen mode

These requirements encouraged developers to add custom JavaScript.


Common beginner mistakes

Treating native email validation as complete verification

<input type="email" />
Enter fullscreen mode Exit fullscreen mode

This only checks the general format. It does not prove that the email exists or belongs to the user.

Using a regular expression for every rule

Large expressions become difficult to read and maintain.

Validation should communicate business meaning, not merely demonstrate regex knowledge.

Relying only on frontend validation

Users can bypass browser validation using:

  • Browser developer tools
  • Direct API clients
  • Automated scripts
  • Modified JavaScript
  • Tools such as curl or Postman

The server must validate the request independently.

Using only color to show validity

A red or green border is not sufficient.

Users with visual impairments or color-vision differences may not understand the result.

Display text errors and use accessibility attributes.


Senior engineer tip

Use native HTML semantics even when you plan to use JavaScript, React, or React Hook Form.

Modern libraries should enhance correct HTML rather than replace it.

Good foundations still include:

<label for="email">Email</label>
<input id="email" name="email" type="email" />
<button type="submit">Submit</button>
Enter fullscreen mode Exit fullscreen mode

Interview question

Why is frontend validation not enough?

Frontend validation improves user experience, but it cannot be trusted for security.

A client can modify or bypass frontend code. Therefore, the server must validate every request before using or storing its data.


Why this evolved

Native HTML validation handled simple rules, but production applications needed custom messages, cross-field validation, business rules, and dynamic UI behavior. Developers therefore moved validation logic into JavaScript.


Stage 3: Vanilla JavaScript Validation

JavaScript gave developers complete control over form behavior.

Instead of immediately allowing the browser to submit the form, JavaScript could:

  • Intercept submission
  • Read input values
  • Validate custom rules
  • Display custom errors
  • Remove old errors
  • Prevent invalid requests
  • Continue submission when data was valid

Intercepting form submission

const form = document.getElementById("register-form");

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

  console.log("Form submission intercepted");
});
Enter fullscreen mode Exit fullscreen mode

The important method is:

event.preventDefault();
Enter fullscreen mode Exit fullscreen mode

A form's default behavior is to submit and navigate.

preventDefault() cancels that default browser action.

Without preventDefault()
Submit → browser request → page navigation

With preventDefault()
Submit → JavaScript receives control
Enter fullscreen mode Exit fullscreen mode

At this point, JavaScript becomes responsible for deciding what happens next.


Selecting elements

getElementById()

const emailInput =
  document.getElementById("email");
Enter fullscreen mode Exit fullscreen mode

This finds the element whose id is email.

<input id="email" />
Enter fullscreen mode Exit fullscreen mode

querySelector()

const form =
  document.querySelector("#register-form");
Enter fullscreen mode Exit fullscreen mode

querySelector() accepts a CSS selector.

Examples:

document.querySelector("#email");
document.querySelector(".error-message");
document.querySelector('input[name="email"]');
document.querySelector("form");
Enter fullscreen mode Exit fullscreen mode

It returns the first matching element.

To select multiple elements:

const inputs = document.querySelectorAll("input");
Enter fullscreen mode Exit fullscreen mode

Reading input values

const email = emailInput.value;
Enter fullscreen mode Exit fullscreen mode

Suppose the user enters:

   karthik@example.com
Enter fullscreen mode Exit fullscreen mode

The value includes the spaces.

Use trim() to remove whitespace from the beginning and end:

const email = emailInput.value.trim();
Enter fullscreen mode Exit fullscreen mode

Now the value becomes:

karthik@example.com
Enter fullscreen mode Exit fullscreen mode

Be careful when trimming passwords.

A password may intentionally contain leading or trailing spaces. Whether trimming is allowed should be a deliberate product rule.


Regular-expression validation

A regular expression can check whether text matches a pattern.

A practical, intentionally simple email check might be:

const emailPattern =
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
Enter fullscreen mode Exit fullscreen mode

Then:

const isValidEmail =
  emailPattern.test(email);
Enter fullscreen mode Exit fullscreen mode

This does not prove that the email exists. It only performs a basic structural check.

Avoid attempting to perfectly reproduce the complete email specification with an enormous frontend regex.


Showing field errors

A common structure is:

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

  <input
    id="email"
    name="email"
    type="email"
  />

  <p
    id="email-error"
    class="error"
  ></p>
</div>
Enter fullscreen mode Exit fullscreen mode

JavaScript can update the error:

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

emailError.textContent =
  "Please enter a valid email address.";
Enter fullscreen mode Exit fullscreen mode

It can also mark the input as invalid:

emailInput.setAttribute(
  "aria-invalid",
  "true"
);
Enter fullscreen mode Exit fullscreen mode

To remove the error:

emailError.textContent = "";

emailInput.setAttribute(
  "aria-invalid",
  "false"
);
Enter fullscreen mode Exit fullscreen mode

Creating reusable error functions

Instead of repeating the same code, create helper functions.

function showError(input, errorElement, message) {
  input.setAttribute("aria-invalid", "true");
  errorElement.textContent = message;
}

function clearError(input, errorElement) {
  input.setAttribute("aria-invalid", "false");
  errorElement.textContent = "";
}
Enter fullscreen mode Exit fullscreen mode

Usage:

showError(
  emailInput,
  emailError,
  "Please enter a valid email address."
);
Enter fullscreen mode Exit fullscreen mode

Or:

clearError(emailInput, emailError);
Enter fullscreen mode Exit fullscreen mode

This is an early example of abstraction.

Developers noticed repeated form operations and moved them into reusable functions.

The same motivation later produced reusable React components, custom hooks, and complete form libraries.


Complete JavaScript-validation example

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

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

    <title>JavaScript Form Validation</title>

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

      form {
        display: flex;
        flex-direction: column;
        gap: 18px;
      }

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

      input {
        padding: 10px;
        border: 1px solid #777;
        border-radius: 4px;
        font-size: 16px;
      }

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

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

      button {
        padding: 10px;
        cursor: pointer;
      }

      .success {
        color: #15803d;
      }
    </style>
  </head>

  <body>
    <h1>Create an account</h1>

    <form id="register-form" novalidate>
      <div class="field">
        <label for="username">Username</label>

        <input
          id="username"
          name="username"
          type="text"
          aria-describedby="username-error"
          aria-invalid="false"
        />

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

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

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

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

      <div class="field">
        <label for="password">Password</label>

        <input
          id="password"
          name="password"
          type="password"
          aria-describedby="password-error"
          aria-invalid="false"
        />

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

      <button type="submit">Register</button>

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

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

      const usernameInput =
        document.getElementById("username");

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

      const passwordInput =
        document.getElementById("password");

      const usernameError =
        document.getElementById("username-error");

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

      const passwordError =
        document.getElementById("password-error");

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

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

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

      function showError(
        input,
        errorElement,
        message
      ) {
        input.setAttribute(
          "aria-invalid",
          "true"
        );

        errorElement.textContent = message;
      }

      function clearError(
        input,
        errorElement
      ) {
        input.setAttribute(
          "aria-invalid",
          "false"
        );

        errorElement.textContent = "";
      }

      function clearAllErrors() {
        clearError(
          usernameInput,
          usernameError
        );

        clearError(
          emailInput,
          emailError
        );

        clearError(
          passwordInput,
          passwordError
        );

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

      function validateUsername(username) {
        if (username.length === 0) {
          return "Username is required.";
        }

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

        if (username.length > 20) {
          return "Username cannot exceed 20 characters.";
        }

        if (!usernamePattern.test(username)) {
          return "Use only letters, numbers, and underscores.";
        }

        return "";
      }

      function validateEmail(email) {
        if (email.length === 0) {
          return "Email is required.";
        }

        if (!emailPattern.test(email)) {
          return "Please enter a valid email address.";
        }

        return "";
      }

      function validatePassword(password) {
        if (password.length === 0) {
          return "Password is required.";
        }

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

        if (!/[A-Z]/.test(password)) {
          return "Password must contain an uppercase letter.";
        }

        if (!/[a-z]/.test(password)) {
          return "Password must contain a lowercase letter.";
        }

        if (!/[0-9]/.test(password)) {
          return "Password must contain a number.";
        }

        return "";
      }

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

          clearAllErrors();

          const username =
            usernameInput.value.trim();

          const email =
            emailInput.value.trim();

          // The password is not trimmed intentionally.
          const password =
            passwordInput.value;

          const usernameMessage =
            validateUsername(username);

          const emailMessage =
            validateEmail(email);

          const passwordMessage =
            validatePassword(password);

          let isValid = true;

          if (usernameMessage) {
            showError(
              usernameInput,
              usernameError,
              usernameMessage
            );

            isValid = false;
          }

          if (emailMessage) {
            showError(
              emailInput,
              emailError,
              emailMessage
            );

            isValid = false;
          }

          if (passwordMessage) {
            showError(
              passwordInput,
              passwordError,
              passwordMessage
            );

            isValid = false;
          }

          if (!isValid) {
            const firstInvalidInput =
              form.querySelector(
                '[aria-invalid="true"]'
              );

            firstInvalidInput?.focus();

            return;
          }

          const registrationData = {
            username,
            email,
            password,
          };

          console.log(
            "Valid registration data:",
            registrationData
          );

          formMessage.textContent =
            "Validation passed. The form is ready to be submitted.";

          formMessage.className = "success";
        }
      );

      usernameInput.addEventListener(
        "input",
        function () {
          clearError(
            usernameInput,
            usernameError
          );
        }
      );

      emailInput.addEventListener(
        "input",
        function () {
          clearError(
            emailInput,
            emailError
          );
        }
      );

      passwordInput.addEventListener(
        "input",
        function () {
          clearError(
            passwordInput,
            passwordError
          );
        }
      );
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

What does novalidate do?

The form contains:

<form id="register-form" novalidate>
Enter fullscreen mode Exit fullscreen mode

novalidate disables the browser's default validation messages for that form.

This allows our JavaScript to display custom errors consistently.

Without novalidate, both systems could become involved:

Browser validation
+
Custom JavaScript validation
Enter fullscreen mode Exit fullscreen mode

That can produce confusing or duplicated behavior.

Using novalidate does not mean the inputs should lose semantic attributes. You can still use meaningful types such as:

<input type="email" />
Enter fullscreen mode Exit fullscreen mode

Mobile browsers may use that information to provide an appropriate keyboard.


JavaScript validation flow

User submits form
        ↓
submit event fires
        ↓
preventDefault()
        ↓
Read input values
        ↓
Normalize selected values
        ↓
Run validation functions
        ↓
Collect validation results
        ↓
Are there errors?
       / \
     Yes  No
      ↓    ↓
Display  Prepare data
errors   for submission
      ↓
Focus first invalid input
Enter fullscreen mode Exit fullscreen mode

Why separate validation functions?

Instead of placing every condition inside the submit handler:

function validateEmail(email) {
  // Email rules
}

function validatePassword(password) {
  // Password rules
}
Enter fullscreen mode Exit fullscreen mode

This provides:

  • Better readability
  • Easier testing
  • Reuse
  • Smaller functions
  • Separation of concerns
  • Easier rule changes

This pattern is an early version of schema validation.

Later, libraries such as Zod allow us to describe the same rules declaratively.

Instead of:

function validateUsername(username) {
  if (username.length === 0) {
    return "Username is required.";
  }

  if (username.length < 3) {
    return "Username is too short.";
  }

  return "";
}
Enter fullscreen mode Exit fullscreen mode

we will eventually write something similar to:

const schema = z.object({
  username: z
    .string()
    .min(1, "Username is required")
    .min(3, "Username is too short"),
});
Enter fullscreen mode Exit fullscreen mode

We are not skipping directly to Zod because understanding the manual version explains what Zod is replacing.


Advantages of JavaScript validation

  • Complete control over error messages
  • Custom UI design
  • Cross-field validation
  • Dynamic rules
  • Immediate feedback
  • Ability to focus invalid fields
  • Conditional validation
  • Better interaction than server-only validation
  • Can validate while typing, on blur, or on submit

Disadvantages of JavaScript validation

  • More code
  • More DOM queries
  • Repeated error-handling logic
  • Validation rules can become scattered
  • Event listeners become difficult to manage
  • Complex forms create large files
  • Manual state tracking becomes difficult
  • Client-side code can still be bypassed
  • Accessibility must be implemented carefully

Common beginner mistakes

Showing errors without clearing them

An old error may remain visible even after the user fixes the input.

Clear errors during an appropriate event:

emailInput.addEventListener("input", () => {
  clearError(emailInput, emailError);
});
Enter fullscreen mode Exit fullscreen mode

For some forms, clearing immediately may be misleading. Another strategy is to revalidate the field and clear the error only when it becomes valid.


Using only one global error

<p id="error">Something is wrong.</p>
Enter fullscreen mode Exit fullscreen mode

This does not tell the user which field needs correction.

Field-level errors are usually clearer.


Validating on every keystroke too aggressively

Suppose the user starts typing:

k
Enter fullscreen mode Exit fullscreen mode

Immediately displaying five errors can feel hostile.

A common strategy is:

First submission → show errors
Afterward → revalidate affected fields as the user edits
Enter fullscreen mode Exit fullscreen mode

Form libraries later formalized these strategies using validation modes.


Forgetting accessibility attributes

An error visually displayed under an input may not automatically be understood by a screen reader.

Connect the input and error:

<input
  id="email"
  aria-describedby="email-error"
  aria-invalid="true"
/>

<p id="email-error">
  Please enter a valid email.
</p>
Enter fullscreen mode Exit fullscreen mode

Trusting JavaScript validation on the backend

Frontend validation exists for user experience.

Backend validation exists for correctness and security.

Both are required.


Senior engineer tip: validation timing matters

Validation can happen at several moments.

Timing Description Benefit Risk
On submit Validate after submission Least distracting Feedback arrives later
On blur Validate after leaving a field Balanced feedback User may miss the message
On change Validate after every change Fast feedback Can be noisy
After first submit Submit first, then revalidate while editing Good production balance More state to manage

Later, React Hook Form will expose these strategies through options such as:

mode: "onSubmit"
mode: "onBlur"
mode: "onChange"
mode: "onTouched"
mode: "all"
Enter fullscreen mode Exit fullscreen mode

Interview question

What does event.preventDefault() do during form submission?

It prevents the browser's default form-submission behavior, which would normally send the request and navigate or refresh the page.

It allows JavaScript to validate or submit the data manually.


Why this evolved

JavaScript gave developers control over validation, but traditional submission still caused page navigation. Modern applications needed to send data, show loading indicators, handle errors, and update the interface without replacing the entire page. This led to AJAX forms.


Stage 4: AJAX Forms

AJAX stands for:

Asynchronous JavaScript and XML
Enter fullscreen mode Exit fullscreen mode

Despite the name, modern AJAX applications commonly exchange JSON rather than XML.

The important idea is asynchronous communication.

JavaScript sends an HTTP request in the background while the current page remains open.


Traditional form versus AJAX form

Traditional submission

User submits
      ↓
Browser sends request
      ↓
Current page is left
      ↓
Server returns another document
      ↓
Browser renders the new page
Enter fullscreen mode Exit fullscreen mode

AJAX submission

User submits
      ↓
JavaScript sends request
      ↓
Current page remains visible
      ↓
Server returns data
      ↓
JavaScript updates part of the page
Enter fullscreen mode Exit fullscreen mode

This made applications feel faster and more interactive.


Why page refreshes became a problem

A complete page navigation can:

  • Lose temporary interface state
  • Reset scroll position
  • Interrupt animations or media
  • Make loading feel slower
  • Require the server to return complete HTML
  • Make small interactions feel heavy
  • Provide limited control over loading and error states

Consider a registration request that takes two seconds.

With a traditional form, the browser may show only a loading indicator in the tab.

With JavaScript, the application can display:

Creating your account...
Enter fullscreen mode Exit fullscreen mode

It can also disable the button to prevent duplicate submissions.


Three common AJAX approaches

Historically, developers used several APIs and libraries.

XMLHttpRequest

XMLHttpRequest was the browser API commonly used for early AJAX applications.

const request = new XMLHttpRequest();

request.open(
  "POST",
  "/api/register"
);

request.setRequestHeader(
  "Content-Type",
  "application/json"
);

request.onload = function () {
  console.log(request.responseText);
};

request.onerror = function () {
  console.error("Network error");
};

request.send(
  JSON.stringify({
    email: "karthik@example.com",
  })
);
Enter fullscreen mode Exit fullscreen mode

It works, but its event-based API can become verbose.


Fetch API

The Fetch API provides a promise-based interface.

const response = await fetch(
  "/api/register",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: "karthik@example.com",
    }),
  }
);
Enter fullscreen mode Exit fullscreen mode

Then parse the JSON response:

const data = await response.json();
Enter fullscreen mode Exit fullscreen mode

One important detail is that Fetch does not reject its promise merely because the server returns a status such as 400 or 500.

You should check:

if (!response.ok) {
  // Handle HTTP error
}
Enter fullscreen mode Exit fullscreen mode

Axios

Axios is a third-party HTTP client.

const response = await axios.post(
  "/api/register",
  {
    email: "karthik@example.com",
  }
);
Enter fullscreen mode Exit fullscreen mode

Axios commonly provides conveniences such as:

  • Automatic JSON transformation
  • Request and response interceptors
  • Configurable instances
  • Familiar error handling
  • Timeout configuration
  • Browser and Node.js support

However, it adds a dependency.

For many browser requests, Fetch is sufficient. Axios can still be useful when a project benefits from its abstractions.


Comparing XMLHttpRequest, Fetch, and Axios

Feature XMLHttpRequest Fetch Axios
Built into browser Yes Yes No
Promise-based by default No Yes Yes
JSON parsing Manual Manual Usually automatic
Rejects on 4xx/5xx Manual handling No Yes
Interceptors No built-in high-level API Not directly Yes
Modern readability Lower High High
Extra dependency No No Yes

The states of an AJAX form

An AJAX form is not simply “submitted” or “not submitted.”

It usually has several states.

Idle
 ↓
Validating
 ↓
Submitting
 ├──→ Success
 └──→ Error
Enter fullscreen mode Exit fullscreen mode

At minimum, production forms usually need:

  • Idle state
  • Loading state
  • Success state
  • Error state

Loading state

While a request is running:

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

After the request finishes:

submitButton.disabled = false;
submitButton.textContent = "Register";
Enter fullscreen mode Exit fullscreen mode

Disabling the button reduces accidental duplicate requests.

However, the backend should still protect itself against duplicated operations when necessary. Client-side disabling is not a complete consistency guarantee.


Success state

When registration succeeds, the UI may:

  • Display a success message
  • Reset the form
  • Redirect to login
  • Display an email-verification instruction
  • Update application state

Example:

message.textContent =
  "Registration successful. Check your email.";

form.reset();
Enter fullscreen mode Exit fullscreen mode

Error state

Errors can come from different sources.

Validation error

The email format is invalid.
Enter fullscreen mode Exit fullscreen mode

Business-rule error

Email already exists.
Enter fullscreen mode Exit fullscreen mode

Authentication or authorization error

Your session has expired.
Enter fullscreen mode Exit fullscreen mode

Server error

Something went wrong on the server.
Enter fullscreen mode Exit fullscreen mode

Network error

The device is offline or the server cannot be reached.
Enter fullscreen mode Exit fullscreen mode

A good form should not display all these situations as:

Something went wrong.
Enter fullscreen mode Exit fullscreen mode

Users need actionable information when it is safe to provide it.


Complete AJAX registration example

Create this structure:

ajax-registration/
├── public/
│   └── index.html
├── package.json
└── server.js
Enter fullscreen mode Exit fullscreen mode

public/index.html

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

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

    <title>AJAX Registration Form</title>

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

      form {
        display: flex;
        flex-direction: column;
        gap: 18px;
      }

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

      input {
        padding: 10px;
        border: 1px solid #777;
        border-radius: 4px;
        font-size: 16px;
      }

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

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

      button {
        padding: 10px;
        cursor: pointer;
      }

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

      .success {
        color: #15803d;
      }

      .failure {
        color: #b91c1c;
      }
    </style>
  </head>

  <body>
    <h1>Create an account</h1>

    <form id="register-form" novalidate>
      <div class="field">
        <label for="username">Username</label>

        <input
          id="username"
          name="username"
          type="text"
          autocomplete="username"
          aria-describedby="username-error"
          aria-invalid="false"
        />

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

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

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

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

      <div class="field">
        <label for="password">Password</label>

        <input
          id="password"
          name="password"
          type="password"
          autocomplete="new-password"
          aria-describedby="password-error"
          aria-invalid="false"
        />

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

      <button
        id="submit-button"
        type="submit"
      >
        Register
      </button>

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

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

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

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

      const fields = {
        username: {
          input:
            document.getElementById("username"),

          error:
            document.getElementById(
              "username-error"
            ),
        },

        email: {
          input:
            document.getElementById("email"),

          error:
            document.getElementById(
              "email-error"
            ),
        },

        password: {
          input:
            document.getElementById("password"),

          error:
            document.getElementById(
              "password-error"
            ),
        },
      };

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

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

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

        if (!field) {
          return;
        }

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

        field.error.textContent = message;
      }

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

        if (!field) {
          return;
        }

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

        field.error.textContent = "";
      }

      function clearAllErrors() {
        Object.keys(fields).forEach(
          clearFieldError
        );

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

      function validateForm(data) {
        const errors = {};

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

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

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

        return errors;
      }

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

        submitButton.textContent = isSubmitting
          ? "Creating account..."
          : "Register";

        fields.username.input.disabled =
          isSubmitting;

        fields.email.input.disabled =
          isSubmitting;

        fields.password.input.disabled =
          isSubmitting;
      }

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

        if (!firstFieldName) {
          return;
        }

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

      async function registerUser(data) {
        const response = await fetch(
          "/api/register",
          {
            method: "POST",

            headers: {
              "Content-Type":
                "application/json",
            },

            body: JSON.stringify(data),
          }
        );

        let responseData;

        try {
          responseData = await response.json();
        } catch {
          responseData = {
            success: false,
            message:
              "The server returned an invalid response.",
          };
        }

        if (!response.ok) {
          const error = new Error(
            responseData.message ||
              "Registration failed."
          );

          error.status = response.status;
          error.data = responseData;

          throw error;
        }

        return responseData;
      }

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

          clearAllErrors();

          const formData = {
            username:
              fields.username.input.value.trim(),

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

            password:
              fields.password.input.value,
          };

          const validationErrors =
            validateForm(formData);

          if (
            Object.keys(validationErrors).length >
            0
          ) {
            Object.entries(
              validationErrors
            ).forEach(
              ([fieldName, message]) => {
                showFieldError(
                  fieldName,
                  message
                );
              }
            );

            focusFirstError(validationErrors);

            return;
          }

          setSubmitting(true);

          formMessage.textContent =
            "Submitting your registration...";

          try {
            const result =
              await registerUser(formData);

            formMessage.textContent =
              result.message;

            formMessage.className = "success";

            form.reset();

            Object.keys(fields).forEach(
              clearFieldError
            );
          } catch (error) {
            console.error(
              "Registration error:",
              error
            );

            const backendError = error.data;

            if (
              backendError?.field &&
              fields[backendError.field]
            ) {
              showFieldError(
                backendError.field,
                backendError.message
              );

              fields[
                backendError.field
              ].input.focus();

              formMessage.textContent =
                "Please correct the highlighted field.";

              formMessage.className =
                "failure";
            } else {
              formMessage.textContent =
                error.message ||
                "Unable to create your account.";

              formMessage.className =
                "failure";
            }
          } finally {
            setSubmitting(false);
          }
        }
      );

      Object.keys(fields).forEach(
        (fieldName) => {
          fields[
            fieldName
          ].input.addEventListener(
            "input",
            function () {
              clearFieldError(fieldName);
            }
          );
        }
      );
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

server.js

const express = require("express");
const path = require("path");

const app = express();
const PORT = 3000;

app.use(express.json());

app.use(
  express.static(
    path.join(__dirname, "public")
  )
);

// A temporary in-memory list for demonstration.
// A production application would use a database.
const users = [
  {
    id: 1,
    username: "existing_user",
    email: "existing@example.com",
  },
];

function isNonEmptyString(value) {
  return (
    typeof value === "string" &&
    value.trim().length > 0
  );
}

app.post(
  "/api/register",
  async (request, response) => {
    const {
      username,
      email,
      password,
    } = request.body;

    // Server-side validation is still required.
    if (!isNonEmptyString(username)) {
      return response.status(400).json({
        success: false,
        field: "username",
        message: "Username is required.",
      });
    }

    if (!isNonEmptyString(email)) {
      return response.status(400).json({
        success: false,
        field: "email",
        message: "Email is required.",
      });
    }

    if (
      typeof password !== "string" ||
      password.length < 8
    ) {
      return response.status(400).json({
        success: false,
        field: "password",
        message:
          "Password must contain at least 8 characters.",
      });
    }

    const normalizedEmail =
      email.trim().toLowerCase();

    const normalizedUsername =
      username.trim();

    const emailAlreadyExists =
      users.some(
        (user) =>
          user.email.toLowerCase() ===
          normalizedEmail
      );

    if (emailAlreadyExists) {
      return response.status(409).json({
        success: false,
        field: "email",
        message:
          "An account with this email already exists.",
      });
    }

    const usernameAlreadyExists =
      users.some(
        (user) =>
          user.username.toLowerCase() ===
          normalizedUsername.toLowerCase()
      );

    if (usernameAlreadyExists) {
      return response.status(409).json({
        success: false,
        field: "username",
        message:
          "This username is already taken.",
      });
    }

    // Simulate database or email-service work.
    await new Promise((resolve) => {
      setTimeout(resolve, 1000);
    });

    const newUser = {
      id: users.length + 1,
      username: normalizedUsername,
      email: normalizedEmail,
    };

    users.push(newUser);

    return response.status(201).json({
      success: true,
      message:
        "Registration successful. Check your email to verify your account.",
      data: {
        user: newUser,
      },
    });
  }
);

app.use(
  (
    error,
    request,
    response,
    next
  ) => {
    console.error(error);

    response.status(500).json({
      success: false,
      message:
        "An unexpected server error occurred.",
    });
  }
);

app.listen(PORT, () => {
  console.log(
    `Server running at http://localhost:${PORT}`
  );
});
Enter fullscreen mode Exit fullscreen mode

Install and run

npm init -y
npm install express
node server.js
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Test the following email:

existing@example.com
Enter fullscreen mode Exit fullscreen mode

The backend returns:

{
  "success": false,
  "field": "email",
  "message": "An account with this email already exists."
}
Enter fullscreen mode Exit fullscreen mode

The frontend reads:

backendError.field
Enter fullscreen mode Exit fullscreen mode

which contains:

email
Enter fullscreen mode Exit fullscreen mode

It then executes:

showFieldError(
  backendError.field,
  backendError.message
);
Enter fullscreen mode Exit fullscreen mode

That becomes:

showFieldError(
  "email",
  "An account with this email already exists."
);
Enter fullscreen mode Exit fullscreen mode

The helper looks up:

fields["email"]
Enter fullscreen mode Exit fullscreen mode

which points to:

{
  input: emailInput,
  error: emailErrorElement
}
Enter fullscreen mode Exit fullscreen mode

Therefore, only the email error is updated.

This is an important idea.

The backend error contains the field name:

{
  "field": "email"
}
Enter fullscreen mode Exit fullscreen mode

The frontend uses that field name as a key:

fields[backendError.field]
Enter fullscreen mode Exit fullscreen mode

Later, React Hook Form will do something conceptually similar:

setError("email", {
  type: "server",
  message: "Email already exists",
});
Enter fullscreen mode Exit fullscreen mode

and expose the result through:

errors.email
Enter fullscreen mode Exit fullscreen mode

Complete AJAX request lifecycle

User enters registration information
                ↓
User clicks Register
                ↓
submit event fires
                ↓
preventDefault()
                ↓
Frontend reads input values
                ↓
Frontend validates values
                ↓
Are there client errors?
          /             \
        Yes              No
         ↓                ↓
Show field errors     Set loading state
                          ↓
                   Send Fetch request
                          ↓
                     Express API
                          ↓
                 Server validates again
                          ↓
              Check database/business rules
                    /              \
                Failure           Success
                   ↓                 ↓
          Return error JSON    Create user
                   ↓                 ↓
         Frontend reads error  Return success JSON
                   ↓                 ↓
        Show field/global      Show success message
        error
                    \              /
                     ↓            ↓
                    Stop loading state
Enter fullscreen mode Exit fullscreen mode

Understanding try, catch, and finally

try {
  const result = await registerUser(data);

  // Success
} catch (error) {
  // Failure
} finally {
  // Always runs
}
Enter fullscreen mode Exit fullscreen mode

try

Contains the operation that may fail.

const result =
  await registerUser(formData);
Enter fullscreen mode Exit fullscreen mode

catch

Runs when the request, parsing, or manually thrown HTTP error fails.

catch (error) {
  console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

finally

Runs after success or failure.

finally {
  setSubmitting(false);
}
Enter fullscreen mode Exit fullscreen mode

This makes finally an appropriate place to restore the button and input states.

Without it, developers often forget to remove the loading state in one error path.


Why check response.ok?

Fetch treats a completed HTTP exchange as a fulfilled promise even when the server returns an error status.

For example:

HTTP/1.1 409 Conflict
Enter fullscreen mode Exit fullscreen mode

The network request completed successfully from Fetch's perspective.

Therefore:

const response = await fetch(...);

if (!response.ok) {
  throw new Error("Request failed");
}
Enter fullscreen mode Exit fullscreen mode

response.ok is generally true for successful 2xx responses.


Why validate on both frontend and backend?

The frontend checks:

if (!emailPattern.test(email)) {
  // Show error
}
Enter fullscreen mode Exit fullscreen mode

The backend also validates:

if (!isNonEmptyString(email)) {
  return response.status(400).json(...);
}
Enter fullscreen mode Exit fullscreen mode

This is intentional duplication at different trust boundaries.

Frontend validation
Purpose: user experience

Backend validation
Purpose: correctness and security
Enter fullscreen mode Exit fullscreen mode

The backend cannot assume the request came from the official frontend.

An attacker or another client can call the API directly:

curl \
  -X POST \
  http://localhost:3000/api/register \
  -H "Content-Type: application/json" \
  -d '{}'
Enter fullscreen mode Exit fullscreen mode

The server must protect itself.


Accessibility in the AJAX example

aria-invalid

<input aria-invalid="true" />
Enter fullscreen mode Exit fullscreen mode

This tells assistive technology that the current field value is invalid.

When the field is valid or has not been marked invalid:

<input aria-invalid="false" />
Enter fullscreen mode Exit fullscreen mode

aria-describedby

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

<p id="email-error">
  Email already exists.
</p>
Enter fullscreen mode Exit fullscreen mode

This creates an association:

Input's aria-describedby
            ↓
Element with matching id
Enter fullscreen mode Exit fullscreen mode

A screen reader can announce the associated description or error.


role="status" and aria-live

<p
  id="form-message"
  role="status"
  aria-live="polite"
></p>
Enter fullscreen mode Exit fullscreen mode

When JavaScript changes the text, assistive technology may announce it without forcing the user to move focus.

Examples:

Submitting your registration...
Registration successful.
Please correct the highlighted field.
Enter fullscreen mode Exit fullscreen mode

Focus management

After validation fails:

firstInvalidInput?.focus();
Enter fullscreen mode Exit fullscreen mode

After a backend field error:

fields[backendError.field].input.focus();
Enter fullscreen mode Exit fullscreen mode

This helps keyboard and screen-reader users reach the field that requires attention.

Do not move focus unpredictably after every keystroke. Focus management should help the user, not fight them.


Advantages of AJAX forms

  • No complete page refresh
  • Better loading feedback
  • Fine-grained success and error states
  • Easier field-level backend errors
  • Can preserve unsent form data
  • Better experience for interactive applications
  • Allows partial page updates
  • Easier integration with JSON APIs
  • Enables single-page application behavior

Disadvantages of AJAX forms

  • More JavaScript
  • More UI states to manage
  • More error paths
  • Loading-state bugs
  • Duplicate-submission risks
  • Accessibility requires deliberate work
  • Network and server errors must be distinguished
  • Validation logic can become duplicated
  • Manual DOM management becomes difficult
  • Large forms create substantial complexity

The complexity is starting to grow

Our form now manages:

Input values
Validation rules
Field errors
Global errors
Loading state
Success state
Disabled state
Focus management
Request construction
Response parsing
Backend field errors
Network failures
Form reset
Accessibility attributes
Enter fullscreen mode Exit fullscreen mode

And this is only a three-field form.

Imagine a production onboarding form with:

  • First name
  • Last name
  • Username
  • Email
  • Phone number
  • Password
  • Confirm password
  • Country
  • State
  • City
  • Postal code
  • Date of birth
  • Preferred language
  • Terms acceptance
  • Marketing preferences
  • Profile image
  • Resume upload
  • Multiple work experiences
  • Multiple education records

The JavaScript would need to track all those fields and their related states.

This is the beginning of the scaling problem.


Common beginner mistakes

Not disabling submission during a request

A user may click the button several times and create multiple requests.

submitButton.disabled = true;
Enter fullscreen mode Exit fullscreen mode

The backend should still be designed safely because users can bypass the frontend.


Clearing the form before the request succeeds

Bad:

form.reset();

await registerUser(data);
Enter fullscreen mode Exit fullscreen mode

If the request fails, the user loses everything they entered.

Better:

const result = await registerUser(data);

form.reset();
Enter fullscreen mode Exit fullscreen mode

Reset only after confirmed success, unless the product deliberately requires another behavior.


Treating every error as a network error

A 409 Conflict response is different from a lost internet connection.

409 Conflict
→ Server responded
→ Email already exists

Network failure
→ Server may not have been reached
Enter fullscreen mode Exit fullscreen mode

These should usually produce different messages.


Forgetting to handle non-JSON responses

The server, proxy, or hosting provider might return HTML or an empty body.

Wrapping JSON parsing can prevent another confusing error:

let data;

try {
  data = await response.json();
} catch {
  data = {
    message: "Invalid server response.",
  };
}
Enter fullscreen mode Exit fullscreen mode

Exposing sensitive server information

Do not return internal stack traces, database queries, or infrastructure details to users.

Bad production response:

{
  "message": "Prisma error P2002 at /app/src/repository/user.ts:82"
}
Enter fullscreen mode Exit fullscreen mode

Better public response:

{
  "success": false,
  "field": "email",
  "message": "An account with this email already exists."
}
Enter fullscreen mode Exit fullscreen mode

Log the detailed internal error securely on the server.


Senior engineer tip: model form state explicitly

Even in Vanilla JavaScript, start thinking of the form as a state machine.

IDLE
  ↓
VALIDATING
  ↓
SUBMITTING
  ↓
SUCCESS or ERROR
Enter fullscreen mode Exit fullscreen mode

Invalid combinations should be avoided.

For example, a form should not normally be:

isSubmitting = true
isSuccess = true
Enter fullscreen mode Exit fullscreen mode

at the same time.

React will later make UI state more explicit, but React does not automatically solve state-design problems. It only gives us better tools for expressing them.


Performance note

In a small Vanilla JavaScript form, updating one error element is efficient:

emailError.textContent =
  "Email already exists.";
Enter fullscreen mode Exit fullscreen mode

Only that DOM node changes.

When React entered the ecosystem, many forms were implemented using controlled inputs. Every keystroke updated React state and caused the component function to run again.

That created a new trade-off:

Manual DOM management
        versus
Declarative state-driven UI
Enter fullscreen mode Exit fullscreen mode

React improved maintainability and predictability, but large controlled forms could produce boilerplate and unnecessary renders.

That problem eventually became one of the reasons React Hook Form gained popularity.


Interview questions

1. What is AJAX?

AJAX is a technique for sending and receiving data asynchronously using JavaScript without requiring a complete page navigation.

Modern AJAX applications commonly use JSON with Fetch or an HTTP library.


2. Does Fetch throw automatically for a 404 or 500 response?

No.

Fetch usually resolves when an HTTP response is received, even if the status is an error.

Check:

if (!response.ok) {
  // Handle HTTP error
}
Enter fullscreen mode Exit fullscreen mode

3. What is the purpose of a loading state?

A loading state tells the user that the request is in progress and helps prevent repeated submissions.

It can also disable fields or buttons while the operation is running.


4. Why should backend errors have a consistent shape?

A consistent error structure allows the frontend to handle errors predictably.

For example:

{
  "success": false,
  "field": "email",
  "message": "Email already exists"
}
Enter fullscreen mode Exit fullscreen mode

The frontend can map field to the corresponding input and display message beside it.


5. What is the difference between a network error and an HTTP error?

A network error means the request could not be completed normally, possibly because the user is offline, the server is unavailable, or the connection failed.

An HTTP error means the server returned a response with an error status such as 400, 401, 404, 409, or 500.


Why this evolved

AJAX improved the user experience, but manual DOM queries, validation functions, loading flags, error elements, and event listeners became difficult to maintain as forms grew. Applications needed a better way to connect UI with changing state. This created the conditions for React forms and controlled components.


Part 1 summary

We began with the browser doing almost everything:

HTML form
    ↓
Browser collects values
    ↓
Browser submits request
    ↓
Browser loads another page
Enter fullscreen mode Exit fullscreen mode

Then we added browser validation:

HTML attributes
    ↓
Browser checks simple constraints
    ↓
Invalid submissions are stopped
Enter fullscreen mode Exit fullscreen mode

Then we added JavaScript:

Intercept submission
    ↓
Read values
    ↓
Apply custom rules
    ↓
Display custom errors
Enter fullscreen mode Exit fullscreen mode

Finally, we introduced AJAX:

Validate
    ↓
Set loading state
    ↓
Send background request
    ↓
Handle success or failure
    ↓
Update only the required UI
Enter fullscreen mode Exit fullscreen mode

Each evolution solved a real problem.

However, each evolution also introduced new complexity.

Stage Main improvement New challenge
Plain HTML Built-in form submission Page navigation and limited validation
HTML validation Basic client-side constraints Limited customization and business rules
JavaScript validation Custom logic and errors Manual DOM and state management
AJAX forms Submission without page refresh Loading, errors, requests, and state complexity

The three-field AJAX example already contains substantial code.

The next stage will introduce React and ask an important question:

What if the interface were produced from state instead of manually changing DOM elements?

That leads us to:

Stage 5: Scaling problems
Stage 6: React controlled forms
Stage 7: Large React forms
Stage 8: The rise of form libraries
Enter fullscreen mode Exit fullscreen mode

Top comments (0)