Technology Aug 24, 2026 · 43 min read

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 si...

DE
DEV Community
by Karthik Reddy
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

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>

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>

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">

In this example, the browser sends the request to:

/register

If the website is running at:

https://example.com

the complete request URL becomes:

https://example.com/register

The action may also contain a complete URL:

<form action="https://api.example.com/register">

The method attribute

The method tells the browser which HTTP method to use.

The two methods traditionally supported by HTML forms are:

GET
POST

GET form

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

If the user enters:

react forms

the browser navigates to something similar to:

/search?query=react+forms

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">

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"
/>

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

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

For example:

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

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

Understanding <label>

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

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

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

label for="email"
        ↓
input id="email"

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"
/>

Better:

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

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

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

Understanding the submit button

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

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>

However, relying on the default can create accidental submissions.

It is better to be explicit:

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

For buttons that should not submit the form, use:

<button type="button">Show password</button>

What happens after clicking Submit?

Suppose the user enters:

Username: karthik
Email: karthik@example.com
Password: secret123

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

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

The default encoding is commonly:

application/x-www-form-urlencoded

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

The server might redirect the user:

HTTP/1.1 302 Found
Location: /login

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

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>

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}`);
});

Install and run

npm init -y
npm install express
node server.js

Open:

http://localhost:3000

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" />

The field may not be included in the submitted request.

Using GET for passwords

<form method="GET">

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>

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"
/>

The server receives:

email=<entered-value>

It does not receive:

user-email=<entered-value>

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

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

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
/>

The browser prevents form submission when the field is empty.

<input required />

This is a boolean attribute. It does not need:

required="true"

Although that may work, the standard shorthand is simply:

required

minlength and maxlength

<input
  id="username"
  name="username"
  type="text"
  minlength="3"
  maxlength="20"
/>

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

For passwords:

<input
  id="password"
  name="password"
  type="password"
  minlength="8"
  maxlength="72"
/>

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_]+"
/>

This accepts:

karthik
karthik_2005
User123

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"
/>

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
/>

The browser checks whether the value resembles an email address.

It may reject:

hello
karthik@
@example.com

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"
/>

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

Minimum: 18
Maximum: 100

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"
/>

Date input

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

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

For example:

2005-08-24

URL input

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

The browser checks whether the value resembles a URL.

Depending on the browser, entering:

example.com

may fail because the protocol is missing.

A complete value would be:

https://example.com

Password input

<input
  id="password"
  name="password"
  type="password"
  required
  minlength="8"
/>

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>

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

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.

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());

You can also assign a custom native validation message:

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

usernameInput.setCustomValidity(
  "This username is not available."
);

To remove the custom error:

usernameInput.setCustomValidity("");

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"
/>

HTML can validate the minimum length of each field.

However, HTML alone cannot easily express:

confirmPassword must equal password

That validation depends on comparing two fields.

Similarly, HTML cannot independently determine:

startDate must be before endDate

or:

at least one communication method must be selected

These requirements encouraged developers to add custom JavaScript.

Common beginner mistakes

Treating native email validation as complete verification

<input type="email" />

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>

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");
});

The important method is:

event.preventDefault();

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

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

Selecting elements

getElementById()

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

This finds the element whose id is email.

<input id="email" />

querySelector()

const form =
  document.querySelector("#register-form");

querySelector() accepts a CSS selector.

Examples:

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

It returns the first matching element.

To select multiple elements:

const inputs = document.querySelectorAll("input");

Reading input values

const email = emailInput.value;

Suppose the user enters:

   karthik@example.com

The value includes the spaces.

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

const email = emailInput.value.trim();

Now the value becomes:

karthik@example.com

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@]+$/;

Then:

const isValidEmail =
  emailPattern.test(email);

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>

JavaScript can update the error:

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

emailError.textContent =
  "Please enter a valid email address.";

It can also mark the input as invalid:

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

To remove the error:

emailError.textContent = "";

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

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 = "";
}

Usage:

showError(
  emailInput,
  emailError,
  "Please enter a valid email address."
);

Or:

clearError(emailInput, emailError);

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>

What does novalidate do?

The form contains:

<form id="register-form" novalidate>

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

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" />

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

Why separate validation functions?

Instead of placing every condition inside the submit handler:

function validateEmail(email) {
  // Email rules
}

function validatePassword(password) {
  // Password rules
}

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 "";
}

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"),
});

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);
});

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>

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

Immediately displaying five errors can feel hostile.

A common strategy is:

First submission → show errors
Afterward → revalidate affected fields as the user edits

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>

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"

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

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

AJAX submission

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

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...

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",
  })
);

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",
    }),
  }
);

Then parse the JSON response:

const data = await response.json();

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
}

Axios

Axios is a third-party HTTP client.

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

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

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...";

After the request finishes:

submitButton.disabled = false;
submitButton.textContent = "Register";

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();

Error state

Errors can come from different sources.

Validation error

The email format is invalid.

Business-rule error

Email already exists.

Authentication or authorization error

Your session has expired.

Server error

Something went wrong on the server.

Network error

The device is offline or the server cannot be reached.

A good form should not display all these situations as:

Something went wrong.

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

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>

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}`
  );
});

Install and run

npm init -y
npm install express
node server.js

Open:

http://localhost:3000

Test the following email:

existing@example.com

The backend returns:

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

The frontend reads:

backendError.field

which contains:

email

It then executes:

showFieldError(
  backendError.field,
  backendError.message
);

That becomes:

showFieldError(
  "email",
  "An account with this email already exists."
);

The helper looks up:

fields["email"]

which points to:

{
  input: emailInput,
  error: emailErrorElement
}

Therefore, only the email error is updated.

This is an important idea.

The backend error contains the field name:

{
  "field": "email"
}

The frontend uses that field name as a key:

fields[backendError.field]

Later, React Hook Form will do something conceptually similar:

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

and expose the result through:

errors.email

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

Understanding try, catch, and finally

try {
  const result = await registerUser(data);

  // Success
} catch (error) {
  // Failure
} finally {
  // Always runs
}

try

Contains the operation that may fail.

const result =
  await registerUser(formData);

catch

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

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

finally

Runs after success or failure.

finally {
  setSubmitting(false);
}

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

The network request completed successfully from Fetch's perspective.

Therefore:

const response = await fetch(...);

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

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
}

The backend also validates:

if (!isNonEmptyString(email)) {
  return response.status(400).json(...);
}

This is intentional duplication at different trust boundaries.

Frontend validation
Purpose: user experience

Backend validation
Purpose: correctness and security

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 '{}'

The server must protect itself.

Accessibility in the AJAX example

aria-invalid

<input aria-invalid="true" />

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" />

aria-describedby

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

<p id="email-error">
  Email already exists.
</p>

This creates an association:

Input's aria-describedby
            ↓
Element with matching id

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>

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.

Focus management

After validation fails:

firstInvalidInput?.focus();

After a backend field error:

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

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

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;

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);

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

Better:

const result = await registerUser(data);

form.reset();

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

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.",
  };
}

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"
}

Better public response:

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

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

Invalid combinations should be avoided.

For example, a form should not normally be:

isSubmitting = true
isSuccess = true

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.";

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

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
}

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"
}

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

Then we added browser validation:

HTML attributes
    ↓
Browser checks simple constraints
    ↓
Invalid submissions are stopped

Then we added JavaScript:

Intercept submission
    ↓
Read values
    ↓
Apply custom rules
    ↓
Display custom errors

Finally, we introduced AJAX:

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

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
DE
Source

This article was originally published by DEV Community and written by Karthik Reddy.

Read original article on DEV Community
Back to Discover

Reading List