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:
- Plain HTML forms
- Native HTML validation
- Vanilla JavaScript validation
- 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:
actionmethod
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:
- The browser sends a POST request.
- The server receives the form fields.
- The browser leaves the registration page.
- 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:
- Stops the form submission.
- Focuses an invalid field.
- Displays a browser-generated message.
- 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
- 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
Top comments (0)