The Evolution of Web Forms — Part 4: Server Validation, Backend Errors, Accessibility, and Performance
In Part 3, we created a modern frontend form using:
React Hook Form
+
Zod
+
TypeScript
That form could:
- Register fields
- Validate data
- Display field errors
- Track dirty and touched fields
- Prevent invalid submission
- Transform email addresses
- Handle loading state
- Receive backend errors through
setError()
However, a production form cannot stop at frontend validation.
Frontend code runs on the user's device.
The user controls:
- The browser
- The JavaScript
- The request body
- The request headers
- The API client
- The timing of requests
A request does not have to come from your React application.
Someone can directly call your backend using:
- Another frontend
- A mobile application
- An automated script
- An API-testing tool
- A modified browser request
Therefore, the server must treat every request as untrusted.
This part covers:
- Server-side validation with Express and Zod
- Mapping backend errors into React Hook Form
- Building accessible production forms
- Understanding form performance and rerenders
Stage 13: Server-Side Validation
Frontend validation exists mainly to improve the user experience.
Backend validation exists to protect the application and preserve correct data.
These responsibilities overlap, but they are not identical.
Frontend validation
├── Fast feedback
├── Better user experience
├── Prevent obvious mistakes
└── Reduce unnecessary requests
Backend validation
├── Enforce trusted rules
├── Protect the database
├── Reject malicious requests
├── Enforce authorization
└── Preserve data integrity
A user can bypass frontend validation completely.
How frontend validation can be bypassed
Suppose your React form requires:
Username: at least 3 characters
Email: valid format
Password: at least 8 characters
Your frontend may correctly reject:
{
"username": "",
"email": "hello",
"password": "12"
}
But someone can send this object directly to your API:
POST /api/auth/register
Content-Type: application/json
{
"username": "",
"email": "hello",
"password": "12"
}
Your React application is not involved.
The request goes directly to Express.
Custom API client
↓
POST /api/auth/register
↓
Express server
If the backend trusts the frontend, invalid data may enter the database.
The backend validation pipeline
A production registration request commonly passes through several layers.
Incoming HTTP request
↓
Parse JSON body
↓
Structural validation
↓
Normalize selected values
↓
Business-rule validation
↓
Database checks
↓
Create user
↓
Return safe response
Each layer answers a different question.
Structural validation
Structural validation checks the shape and basic format of the request.
Examples:
Is username a string?
Is email present?
Does email have a valid format?
Is password long enough?
Zod is useful here.
Business-rule validation
Business validation checks application-specific rules.
Examples:
Is the username reserved?
Is registration currently allowed?
Can this organization add more users?
Is the user old enough for this service?
These rules may require service logic beyond a schema.
Database validation
Some rules require querying the database.
Examples:
Does the email already exist?
Is the username already taken?
Is the invitation token valid?
Has this reset token already been used?
Zod cannot answer these questions by itself.
Authorization validation
Authorization asks whether the current user is permitted to perform the operation.
Examples:
Can this user edit this profile?
Can this manager create another employee?
Can this member delete this organization?
A valid request body does not imply that the requester has permission.
Shared validation schema
In a TypeScript monorepo, structural validation can often be shared.
apps/
├── web/
└── api/
packages/
└── validation/
└── auth.schemas.ts
Create:
packages/validation/auth.schemas.ts
import { z } from "zod";
export const registerSchema = z.object({
username: z
.string()
.trim()
.min(1, "Username is required.")
.min(
3,
"Username must contain at least 3 characters.",
)
.max(
20,
"Username cannot exceed 20 characters.",
)
.regex(
/^[A-Za-z0-9_]+$/,
"Use only letters, numbers, and underscores.",
),
displayName: z
.string()
.trim()
.min(1, "Display name is required.")
.min(
2,
"Display name must contain at least 2 characters.",
)
.max(
50,
"Display name cannot exceed 50 characters.",
),
email: z
.string()
.trim()
.min(1, "Email is required.")
.email("Enter a valid email address.")
.transform((email) => email.toLowerCase()),
password: z
.string()
.min(1, "Password is required.")
.min(
8,
"Password must contain at least 8 characters.",
)
.max(
72,
"Password cannot exceed 72 characters.",
),
acceptTerms: z.literal(true, {
message: "You must accept the terms.",
}),
});
export type RegisterInput = z.input<
typeof registerSchema
>;
export type RegisterOutput = z.output<
typeof registerSchema
>;
The frontend can use this schema through zodResolver().
The backend can use the same schema through safeParse().
registerSchema
/ \
/ \
React frontend Express backend
zodResolver() safeParse()
Sharing the schema reduces accidental differences between frontend and backend structural rules.
It does not remove the backend's responsibility to validate.
Why use safeParse() on the server?
The backend receives data of an unknown shape.
Even when TypeScript says:
request.body
exists, the actual runtime value may be anything.
null
[]
{
"email": 123
}
{
"unexpected": "value"
}
Use:
const result = registerSchema.safeParse(
request.body,
);
The result has two possible forms.
Success
├── success: true
└── data: validated and transformed values
Failure
├── success: false
└── error: validation details
Designing a consistent API error response
The frontend should not need to guess the structure of an error.
A useful error response may contain:
interface ApiErrorResponse {
success: false;
code: string;
message: string;
field?: string;
fieldErrors?: Record<string, string>;
}
Each property has a role.
success
→ Indicates request failure
code
→ Stable machine-readable identifier
message
→ Human-readable explanation
field
→ One field related to the failure
fieldErrors
→ Multiple field-level failures
Example:
{
"success": false,
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "An account with this email already exists."
}
The frontend can use code for logic and message for display.
Do not make frontend logic depend entirely on exact English sentences.
Bad:
if (
error.message ===
"An account with this email already exists."
) {
// ...
}
Better:
if (
error.code ===
"EMAIL_ALREADY_EXISTS"
) {
// ...
}
Messages can change because of:
- Wording improvements
- Translation
- Product changes
- Localization
Stable error codes are safer for application logic.
Complete Express 5 backend
Create the following structure:
server/
├── src/
│ ├── app.ts
│ ├── errors/
│ │ └── ApiError.ts
│ ├── middleware/
│ │ ├── errorHandler.ts
│ │ └── validate.ts
│ ├── routes/
│ │ └── auth.routes.ts
│ ├── schemas/
│ │ └── auth.schemas.ts
│ └── services/
│ └── auth.service.ts
├── package.json
└── tsconfig.json
Install the dependencies:
npm install express zod bcrypt
npm install --save-dev typescript tsx \
@types/express @types/node @types/bcrypt
src/schemas/auth.schemas.ts
import { z } from "zod";
export const registerSchema = z.object({
username: z
.string()
.trim()
.min(1, "Username is required.")
.min(
3,
"Username must contain at least 3 characters.",
)
.max(
20,
"Username cannot exceed 20 characters.",
)
.regex(
/^[A-Za-z0-9_]+$/,
"Use only letters, numbers, and underscores.",
),
displayName: z
.string()
.trim()
.min(1, "Display name is required.")
.min(
2,
"Display name must contain at least 2 characters.",
)
.max(
50,
"Display name cannot exceed 50 characters.",
),
email: z
.string()
.trim()
.min(1, "Email is required.")
.email("Enter a valid email address.")
.transform((email) => email.toLowerCase()),
password: z
.string()
.min(1, "Password is required.")
.min(
8,
"Password must contain at least 8 characters.",
)
.max(
72,
"Password cannot exceed 72 characters.",
),
});
export type RegisterInput = z.input<
typeof registerSchema
>;
export type RegisterOutput = z.output<
typeof registerSchema
>;
src/errors/ApiError.ts
export interface ApiErrorDetails {
code: string;
field?: string;
fieldErrors?: Record<string, string>;
}
export default class ApiError extends Error {
readonly statusCode: number;
readonly code: string;
readonly field?: string;
readonly fieldErrors?: Record<
string,
string
>;
constructor(
statusCode: number,
message: string,
details: ApiErrorDetails,
) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.code = details.code;
this.field = details.field;
this.fieldErrors =
details.fieldErrors;
Error.captureStackTrace?.(
this,
ApiError,
);
}
}
src/middleware/validate.ts
import type {
NextFunction,
Request,
RequestHandler,
Response,
} from "express";
import type {
ZodType,
} from "zod";
import ApiError from "../errors/ApiError";
function createFieldErrors(
issues: Array<{
path: PropertyKey[];
message: string;
}>,
): Record<string, string> {
const fieldErrors: Record<
string,
string
> = {};
for (const issue of issues) {
const fieldPath = issue.path.join(".");
if (
fieldPath &&
!fieldErrors[fieldPath]
) {
fieldErrors[fieldPath] =
issue.message;
}
}
return fieldErrors;
}
export function validateBody<T>(
schema: ZodType<T>,
): RequestHandler {
return (
request: Request,
_response: Response,
next: NextFunction,
) => {
const result = schema.safeParse(
request.body,
);
if (!result.success) {
const fieldErrors =
createFieldErrors(
result.error.issues,
);
next(
new ApiError(
400,
"Please correct the invalid fields.",
{
code: "VALIDATION_ERROR",
fieldErrors,
},
),
);
return;
}
/*
* Replace the untrusted request body with
* Zod's validated and transformed output.
*/
request.body = result.data;
next();
};
}
The middleware replaces:
request.body
with:
result.data
That matters because Zod may normalize the values.
For example:
" USER@EXAMPLE.COM "
↓
"user@example.com"
The service receives validated output rather than the original untrusted object.
Why convert Zod issues into field errors?
Zod issues may resemble:
[
{
path: ["email"],
message:
"Enter a valid email address.",
},
{
path: ["password"],
message:
"Password must contain at least 8 characters.",
},
]
The frontend usually wants:
{
email:
"Enter a valid email address.",
password:
"Password must contain at least 8 characters.",
}
The conversion creates an API format that is easier for any frontend to consume.
src/services/auth.service.ts
The following example uses an in-memory array so that it can run without a database.
A production application should replace this repository logic with a real database and a unique constraint.
import bcrypt from "bcrypt";
import ApiError from "../errors/ApiError";
import type {
RegisterOutput,
} from "../schemas/auth.schemas";
interface StoredUser {
id: string;
username: string;
displayName: string;
email: string;
hashedPassword: string;
}
interface PublicUser {
id: string;
username: string;
displayName: string;
email: string;
}
const users: StoredUser[] = [
{
id: crypto.randomUUID(),
username: "existing_user",
displayName: "Existing User",
email: "existing@example.com",
hashedPassword:
"$2b$10$demonstrationHash",
},
];
function findUserByEmail(
email: string,
): StoredUser | undefined {
return users.find(
(user) => user.email === email,
);
}
function findUserByUsername(
username: string,
): StoredUser | undefined {
const normalizedUsername =
username.toLowerCase();
return users.find(
(user) =>
user.username.toLowerCase() ===
normalizedUsername,
);
}
export async function registerUser(
input: RegisterOutput,
): Promise<PublicUser> {
const existingEmailUser =
findUserByEmail(input.email);
if (existingEmailUser) {
throw new ApiError(
409,
"An account with this email already exists.",
{
code: "EMAIL_ALREADY_EXISTS",
field: "email",
},
);
}
const existingUsernameUser =
findUserByUsername(
input.username,
);
if (existingUsernameUser) {
throw new ApiError(
409,
"This username is already taken.",
{
code: "USERNAME_ALREADY_EXISTS",
field: "username",
},
);
}
/*
* Never store the original password.
* Store a password hash.
*/
const hashedPassword =
await bcrypt.hash(
input.password,
12,
);
const user: StoredUser = {
id: crypto.randomUUID(),
username: input.username,
displayName: input.displayName,
email: input.email,
hashedPassword,
};
users.push(user);
return {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
};
}
Why the service checks uniqueness
Zod can validate:
email has a valid structure
Zod cannot determine:
email is absent from the database
The database or repository must answer that question.
Schema validation
↓
Email format is valid
↓
Repository query
↓
Does email already exist?
A database unique constraint is still necessary
This code is not sufficient by itself:
const existingUser =
await findUserByEmail(email);
if (existingUser) {
throw new Error(
"Email already exists",
);
}
await createUser(input);
Two requests may execute concurrently:
Request A checks email
→ not found
Request B checks email
→ not found
Request A inserts user
Request B inserts user
The database should enforce uniqueness.
Conceptually:
CREATE UNIQUE INDEX users_email_unique
ON users (email);
The service may perform a friendly pre-check, but it should also handle the database's unique-constraint error.
Application check
→ Better error message
Database constraint
→ Final integrity guarantee
src/routes/auth.routes.ts
import {
Router,
} from "express";
import {
registerSchema,
} from "../schemas/auth.schemas";
import {
validateBody,
} from "../middleware/validate";
import {
registerUser,
} from "../services/auth.service";
const authRouter = Router();
authRouter.post(
"/register",
validateBody(registerSchema),
async (request, response) => {
const user = await registerUser(
request.body,
);
response.status(201).json({
success: true,
message:
"Registration successful. Check your email to verify your account.",
data: {
user,
},
});
},
);
export default authRouter;
In Express 5, rejected promises from async route handlers are forwarded to error-handling middleware, so errors thrown by the awaited service can reach the central error handler without manually wrapping every route in try/catch.
src/middleware/errorHandler.ts
import type {
ErrorRequestHandler,
} from "express";
import ApiError from "../errors/ApiError";
export const errorHandler:
ErrorRequestHandler = (
error,
_request,
response,
_next,
) => {
if (error instanceof ApiError) {
response
.status(error.statusCode)
.json({
success: false,
code: error.code,
message: error.message,
field: error.field,
fieldErrors:
error.fieldErrors,
});
return;
}
/*
* Log the full internal error securely.
* Do not send its stack trace to clients.
*/
console.error(error);
response.status(500).json({
success: false,
code: "INTERNAL_SERVER_ERROR",
message:
"An unexpected error occurred.",
});
};
src/app.ts
import express from "express";
import authRouter from "./routes/auth.routes";
import {
errorHandler,
} from "./middleware/errorHandler";
const app = express();
const port = 3000;
app.use(
express.json({
limit: "100kb",
}),
);
app.use(
"/api/auth",
authRouter,
);
app.use(
(
_request,
response,
) => {
response.status(404).json({
success: false,
code: "ROUTE_NOT_FOUND",
message:
"The requested resource was not found.",
});
},
);
/*
* Error middleware must be registered after
* routes and normal middleware.
*/
app.use(errorHandler);
const server = app.listen(
port,
(error) => {
if (error) {
throw error;
}
console.log(
`API running on port ${port}`,
);
},
);
export default server;
Successful registration response
{
"success": true,
"message": "Registration successful. Check your email to verify your account.",
"data": {
"user": {
"id": "generated-user-id",
"username": "karthik_2005",
"displayName": "Karthik",
"email": "karthik@example.com"
}
}
}
Notice that the response does not return:
password
hashedPassword
Sensitive internal values should not be included in the public response.
Structural validation response
Request:
{
"username": "a",
"displayName": "",
"email": "invalid-email",
"password": "12"
}
Response:
{
"success": false,
"code": "VALIDATION_ERROR",
"message": "Please correct the invalid fields.",
"fieldErrors": {
"username": "Username must contain at least 3 characters.",
"displayName": "Display name is required.",
"email": "Enter a valid email address.",
"password": "Password must contain at least 8 characters."
}
}
Duplicate email response
{
"success": false,
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "An account with this email already exists."
}
Username already taken response
{
"success": false,
"code": "USERNAME_ALREADY_EXISTS",
"field": "username",
"message": "This username is already taken."
}
Invalid token validation
Not every server error belongs to a normal text field.
Suppose the user visits:
/verify-email?token=abc
The server must validate:
- Token exists
- Token has the correct format
- Token hash exists in the database
- Token has not expired
- Token has not already been used
- User still exists
Example:
import { z } from "zod";
const verifyEmailSchema = z.object({
token: z
.string()
.min(
32,
"Verification token is invalid.",
),
});
app.post(
"/api/auth/verify-email",
async (request, response) => {
const result =
verifyEmailSchema.safeParse(
request.body,
);
if (!result.success) {
throw new ApiError(
400,
"The verification link is invalid.",
{
code:
"INVALID_VERIFICATION_TOKEN",
},
);
}
const verification =
await findVerificationByToken(
result.data.token,
);
if (!verification) {
throw new ApiError(
400,
"The verification link is invalid.",
{
code:
"INVALID_VERIFICATION_TOKEN",
},
);
}
if (
verification.expiresAt <=
new Date()
) {
throw new ApiError(
410,
"The verification link has expired.",
{
code:
"VERIFICATION_TOKEN_EXPIRED",
},
);
}
if (verification.usedAt) {
throw new ApiError(
409,
"This verification link has already been used.",
{
code:
"VERIFICATION_TOKEN_USED",
},
);
}
await verifyUserEmail(
verification,
);
response.json({
success: true,
message:
"Email verified successfully.",
});
},
);
A token error usually becomes a page-level message rather than an input error.
Expired session validation
A protected route might reject an expired access token:
{
"success": false,
"code": "SESSION_EXPIRED",
"message": "Your session has expired. Please sign in again."
}
The frontend should not inject that into:
errors.email
because the email field is not the problem.
It may instead:
- Show a root error
- Attempt token refresh
- Redirect to login
- Preserve unsaved form data
- Display a session-expired dialog
Validation belongs at multiple layers
A production application may validate the same logical field several times.
HTML input
→ Basic browser semantics
React Hook Form + Zod
→ Immediate frontend feedback
Express + Zod
→ Trusted request validation
Service layer
→ Business rules
Database
→ Data constraints
This is not pointless duplication.
Each layer protects a different boundary.
Security considerations
Never trust hidden fields
A frontend may submit:
<input
type="hidden"
name="role"
value="user"
/>
The user can change it to:
admin
The backend should decide privileged values itself.
Bad:
const user = await createUser({
...request.body,
});
Better:
const user = await createUser({
username:
request.body.username,
email:
request.body.email,
role: "user",
});
Never return password hashes
Bad:
{
"user": {
"email": "user@example.com",
"hashedPassword": "$2b$..."
}
}
Password hashes are sensitive internal data.
Do not reveal internal errors
Bad:
{
"message": "Prisma P2002 in src/repositories/user.repository.ts line 41"
}
Better:
{
"success": false,
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "An account with this email already exists."
}
Log detailed technical information on the server, not in the public response.
Rate-limit sensitive routes
Registration, login, verification resend, password reset, and similar routes can be abused.
Validation does not replace rate limiting.
Valid-looking request
≠
Legitimate request
Advantages of server-side validation
- Cannot be bypassed by disabling frontend JavaScript
- Protects the database
- Enforces business rules
- Supports every type of client
- Provides a trusted validation boundary
- Protects authorization decisions
- Produces consistent API contracts
Disadvantages and costs
- Adds server code
- Some rules appear on both frontend and backend
- Database checks add latency
- Error formats must be designed carefully
- Shared schemas need version coordination
- Async business validation is more complex
- Race conditions still require database constraints
Common beginner mistakes
Trusting request.body
Bad:
const input =
request.body as RegisterInput;
This is a TypeScript assertion, not runtime validation.
Better:
const result =
registerSchema.safeParse(
request.body,
);
Returning only "Invalid request"
This is technically safe but provides a poor user experience.
Better:
{
"code": "VALIDATION_ERROR",
"message": "Please correct the invalid fields.",
"fieldErrors": {
"email": "Enter a valid email address."
}
}
Do not reveal sensitive internal information, but provide enough information for the user to correct ordinary input mistakes.
Checking uniqueness only in application code
A pre-query can race with another request.
Use database constraints as the final guarantee.
Mixing every concern into the route
Avoid:
router.post(
"/register",
async (request, response) => {
// Validation
// Database query
// Password hashing
// Email sending
// Logging
// Response mapping
// Error conversion
},
);
Prefer layers:
Route
→ Middleware
→ Service
→ Repository
Interview questions
Why is frontend validation not sufficient?
Frontend code runs in an untrusted environment and can be modified or bypassed. The server must validate every request before using its data.
What validation belongs in Zod?
Zod is well suited for runtime structure, types, formats, transformations, and cross-field rules.
Database uniqueness, authorization, and token validity require trusted application or database logic.
Why should error responses contain codes?
Machine-readable codes provide stable logic even when human-readable messages change or are translated.
Why is a database unique constraint necessary if the service already checks for duplicate emails?
Concurrent requests can both pass the service check before either inserts. The database constraint provides the final integrity guarantee.
Why this evolved
Server validation protected the application, but its errors still needed to reach the correct location in the frontend. Developers needed a consistent way to transform HTTP errors into field errors, root errors, redirects, and retry states. React Hook Form’s
setError()API provides that bridge.
Stage 14: Showing Backend Errors with setError()
Consider the complete registration flow.
The frontend validates:
Email format is correct
The backend discovers:
Email already exists
The backend responds:
{
"success": false,
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "An account with this email already exists."
}
The frontend must:
- Catch the failed HTTP request.
- Read the response body.
- Recognize that the error belongs to
email. - Insert the error into React Hook Form.
- Display it under the email input.
- Optionally focus the email input.
Backend error types
Backend failures can be grouped into four categories.
Backend errors
├── Field error
├── Multiple field errors
├── Root/form error
└── Authentication/navigation error
Field error
{
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "Email already exists."
}
Use:
setError("email", {
type: "server",
message:
"Email already exists.",
});
Multiple field errors
{
"code": "VALIDATION_ERROR",
"message": "Please correct the invalid fields.",
"fieldErrors": {
"email": "Enter a valid email address.",
"password": "Password is too short."
}
}
Call setError() for each field. The React Hook Form documentation defines setError() as setting one error per call, so multiple field failures are commonly handled by iterating over the response.
Root error
{
"code": "EMAIL_SERVICE_UNAVAILABLE",
"message": "Your account was created, but the verification email could not be sent."
}
Use:
setError("root.server", {
type: "server",
message:
"Your account was created, but the verification email could not be sent.",
});
Authentication or navigation error
{
"code": "SESSION_EXPIRED",
"message": "Your session has expired."
}
Possible handling:
clearSession();
router.push("/login");
Not every API error should become a field error.
Complete TypeScript error contract
Create:
src/features/auth/auth.types.ts
export const registerFieldNames = [
"username",
"displayName",
"email",
"password",
] as const;
export type RegisterFieldName =
(typeof registerFieldNames)[number];
export interface ApiSuccessResponse<T> {
success: true;
message: string;
data: T;
}
export interface ApiErrorResponse {
success: false;
code: string;
message: string;
field?: string;
fieldErrors?: Record<
string,
string
>;
}
export interface RegisteredUser {
id: string;
username: string;
displayName: string;
email: string;
}
export interface RegisterResponseData {
user: RegisteredUser;
}
Why is field typed as string?
You may be tempted to write:
field?: RegisterFieldName;
However, the response comes from the network.
Runtime data does not become safe merely because a TypeScript interface says it is safe.
The server might return:
{
"field": "unexpectedField"
}
The frontend should verify the value.
Create a runtime field-name guard
import {
registerFieldNames,
} from "./auth.types";
import type {
RegisterFieldName,
} from "./auth.types";
export function isRegisterFieldName(
value: unknown,
): value is RegisterFieldName {
return (
typeof value === "string" &&
registerFieldNames.some(
(fieldName) =>
fieldName === value,
)
);
}
Usage:
if (
isRegisterFieldName(
response.field,
)
) {
setError(
response.field,
{
type: "server",
message:
response.message,
},
);
}
After the guard, TypeScript knows that response.field is one of the supported form-field names.
Creating an Axios API layer
Install Axios:
npm install axios
Create:
src/lib/api.ts
import axios from "axios";
export const api = axios.create({
baseURL:
import.meta.env.VITE_API_URL ??
"http://localhost:3000/api",
timeout: 10_000,
headers: {
"Content-Type":
"application/json",
},
withCredentials: true,
});
A configured instance provides one place for:
- Base URL
- Timeouts
- Cookies
- Common headers
- Interceptors
- Authentication refresh logic
Axios rejects unsuccessful HTTP responses by default, and its error object can distinguish a server response, a request with no response, and request-setup failures. Its isAxiosError() helper provides safe access to Axios-specific properties such as response, config, and code.
Create the registration API function
Create:
src/features/auth/auth-api.ts
import type {
ApiSuccessResponse,
RegisterResponseData,
} from "./auth.types";
import type {
RegisterOutput,
} from "./register.schema";
import {
api,
} from "../../lib/api";
export async function registerUser(
input: RegisterOutput,
): Promise<
ApiSuccessResponse<
RegisterResponseData
>
> {
const response =
await api.post<
ApiSuccessResponse<
RegisterResponseData
>
>(
"/auth/register",
input,
);
return response.data;
}
The API function does not contain form-specific behavior.
It does not call:
setError()
That belongs to the form layer.
API layer
→ Sends and receives HTTP data
Form layer
→ Decides how errors affect the UI
Complete React Hook Form integration
Create:
src/features/auth/RegisterForm.tsx
import axios from "axios";
import {
zodResolver,
} from "@hookform/resolvers/zod";
import {
useForm,
} from "react-hook-form";
import {
registerSchema,
} from "./register.schema";
import type {
RegisterInput,
RegisterOutput,
} from "./register.schema";
import {
registerUser,
} from "./auth-api";
import type {
ApiErrorResponse,
} from "./auth.types";
import {
isRegisterFieldName,
} from "./field-name.utils";
const defaultValues:
RegisterInput = {
username: "",
displayName: "",
email: "",
password: "",
acceptTerms: false,
};
export default function RegisterForm() {
const {
register,
handleSubmit,
setError,
clearErrors,
reset,
formState: {
errors,
isDirty,
isSubmitting,
isSubmitSuccessful,
},
} =
useForm<
RegisterInput,
unknown,
RegisterOutput
>({
defaultValues,
resolver:
zodResolver(
registerSchema,
),
mode: "onBlur",
});
async function onSubmit(
values: RegisterOutput,
) {
clearErrors("root.server");
try {
const response =
await registerUser(
values,
);
console.log(
response.data.user,
);
reset();
/*
* In a real application:
* router.push("/verify-email-sent");
*/
} catch (error: unknown) {
if (
!axios.isAxiosError<
ApiErrorResponse
>(error)
) {
setError(
"root.server",
{
type: "unknown",
message:
"An unexpected error occurred.",
},
);
return;
}
/*
* Axios error with no response:
* network failure, CORS failure,
* timeout, or server unavailable.
*/
if (!error.response) {
const message =
error.code ===
"ECONNABORTED" ||
error.code ===
"ETIMEDOUT"
? "The request timed out. Please try again."
: "Unable to reach the server. Check your connection and try again.";
setError(
"root.server",
{
type: "network",
message,
},
);
return;
}
const apiError =
error.response.data;
/*
* Handle multiple backend field errors.
*/
if (
apiError.fieldErrors
) {
let firstInvalidField:
| string
| undefined;
for (const [
fieldName,
message,
] of Object.entries(
apiError.fieldErrors,
)) {
if (
!isRegisterFieldName(
fieldName,
)
) {
continue;
}
firstInvalidField ??=
fieldName;
setError(
fieldName,
{
type: "server",
message,
},
);
}
if (
firstInvalidField &&
isRegisterFieldName(
firstInvalidField,
)
) {
setError(
firstInvalidField,
{
type: "server",
message:
apiError.fieldErrors[
firstInvalidField
],
},
{
shouldFocus: true,
},
);
}
return;
}
/*
* Handle one backend field error.
*/
if (
isRegisterFieldName(
apiError.field,
)
) {
setError(
apiError.field,
{
type: "server",
message:
apiError.message,
},
{
shouldFocus: true,
},
);
return;
}
/*
* Handle non-field API errors.
*/
setError(
"root.server",
{
type:
apiError.code,
message:
apiError.message,
},
);
}
}
return (
<form
className="form"
onSubmit={
handleSubmit(onSubmit)
}
noValidate
>
<h1>Create an account</h1>
<div className="field">
<label htmlFor="username">
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
aria-invalid={Boolean(
errors.username,
)}
aria-describedby={
errors.username
? "username-error"
: "username-hint"
}
{...register(
"username",
{
onChange: () => {
clearErrors(
"username",
);
},
},
)}
/>
<p
id="username-hint"
className="hint"
>
Use 3–20 letters,
numbers, or underscores.
</p>
{errors.username && (
<p
id="username-error"
className="error"
>
{
errors.username
.message
}
</p>
)}
</div>
<div className="field">
<label htmlFor="displayName">
Display name
</label>
<input
id="displayName"
type="text"
autoComplete="name"
aria-invalid={Boolean(
errors.displayName,
)}
aria-describedby={
errors.displayName
? "displayName-error"
: undefined
}
{...register(
"displayName",
)}
/>
{errors.displayName && (
<p
id="displayName-error"
className="error"
>
{
errors.displayName
.message
}
</p>
)}
</div>
<div className="field">
<label htmlFor="email">
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={Boolean(
errors.email,
)}
aria-describedby={
errors.email
? "email-error"
: undefined
}
{...register(
"email",
{
onChange: () => {
clearErrors([
"email",
"root.server",
]);
},
},
)}
/>
{errors.email && (
<p
id="email-error"
className="error"
>
{errors.email.message}
</p>
)}
</div>
<div className="field">
<label htmlFor="password">
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
aria-invalid={Boolean(
errors.password,
)}
aria-describedby={
errors.password
? "password-error"
: "password-hint"
}
{...register(
"password",
)}
/>
<p
id="password-hint"
className="hint"
>
Use at least eight
characters.
</p>
{errors.password && (
<p
id="password-error"
className="error"
>
{
errors.password
.message
}
</p>
)}
</div>
<div className="field">
<label className="checkbox">
<input
type="checkbox"
aria-invalid={Boolean(
errors.acceptTerms,
)}
aria-describedby={
errors.acceptTerms
? "acceptTerms-error"
: undefined
}
{...register(
"acceptTerms",
)}
/>
<span>
I accept the terms.
</span>
</label>
{errors.acceptTerms && (
<p
id="acceptTerms-error"
className="error"
>
{
errors.acceptTerms
.message
}
</p>
)}
</div>
{errors.root?.server && (
<div
className="alert"
role="alert"
>
{
errors.root.server
.message
}
</div>
)}
{isSubmitSuccessful && (
<p
className="success"
role="status"
>
Registration completed.
</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating account..."
: "Create account"}
</button>
{isDirty && (
<p className="hint">
You have unsaved changes.
</p>
)}
</form>
);
}
Step-by-step flow for a duplicate email
Suppose the user enters:
existing@example.com
Step 1: React Hook Form collects the values
{
username: "karthik_2005",
displayName: "Karthik",
email: "existing@example.com",
password: "Password123",
acceptTerms: true
}
Step 2: Zod validates the frontend data
The email has a valid format, so frontend validation passes.
existing@example.com
↓
Valid email structure
Frontend validation cannot know whether it already exists in the database.
Step 3: registerUser() sends the request
await api.post(
"/auth/register",
values,
);
Step 4: Express validates the request again
registerSchema.safeParse(
request.body,
);
The structure is valid.
Step 5: The service queries the database
const existingUser =
await findUserByEmail(
input.email,
);
The user exists.
Step 6: The backend throws an API error
throw new ApiError(
409,
"An account with this email already exists.",
{
code:
"EMAIL_ALREADY_EXISTS",
field: "email",
},
);
Step 7: Error middleware sends JSON
{
"success": false,
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "An account with this email already exists."
}
Step 8: Axios rejects the promise
The request returned 409 Conflict.
Execution moves into:
catch (error) {
// ...
}
Step 9: isAxiosError() narrows the error
if (
axios.isAxiosError<
ApiErrorResponse
>(error)
) {
// Axios-specific properties
// are safely available.
}
Now we can access:
error.response?.data
Step 10: The frontend reads the field name
const apiError =
error.response.data;
apiError.field;
// "email"
Step 11: The runtime guard verifies the field
isRegisterFieldName(
apiError.field,
);
Result:
true
Step 12: setError() inserts the error
setError(
"email",
{
type: "server",
message:
"An account with this email already exists.",
},
{
shouldFocus: true,
},
);
React Hook Form supports assigning custom errors to registered fields and optionally focusing the related input.
Step 13: React Hook Form updates its error state
Conceptually, the error object becomes:
{
email: {
type: "server",
message:
"An account with this email already exists."
}
}
This is exposed through:
formState.errors
Therefore:
errors.email
contains the email error.
Step 14: Only the email UI reads errors.email
The email input contains:
aria-invalid={Boolean(
errors.email,
)}
The email error paragraph contains:
{errors.email && (
<p id="email-error">
{errors.email.message}
</p>
)}
The username input reads:
errors.username
The password input reads:
errors.password
They do not read:
errors.email
Therefore, the email message appears only beside the email field.
Backend-error lifecycle diagram
User submits registration
↓
Frontend Zod validation
↓
Axios POST request
↓
Express validation
↓
Database uniqueness check
↓
Email already exists
↓
409 response:
field = "email"
↓
Axios throws error
↓
catch(error)
↓
error.response.data
↓
setError("email", ...)
↓
React Hook Form error store
↓
errors.email
↓
Email component renders message
How does React Hook Form know which field is email?
It does not inspect the visual label.
It does not search for the word “email” in the page.
It does not guess based on type="email".
The connection was established when the field was registered:
register("email")
Conceptually:
Field path: "email"
↓
Registered input reference
↓
Value, validation, errors,
dirty state, touched state
Then:
setError("email", ...)
uses the same field path.
register("email")
↕
setError("email")
↕
errors.email
The string "email" is the shared key.
Nested backend errors
Suppose the form contains:
{
address: {
city: "",
},
}
The input is registered as:
register("address.city")
The backend may return:
{
"field": "address.city",
"message": "This city is not supported."
}
The frontend can call:
setError(
"address.city",
{
type: "server",
message:
"This city is not supported.",
},
);
The error becomes available through:
errors.address?.city
Field paths provide the association for nested data.
Mapping multiple backend errors
Create a reusable helper:
import type {
FieldPath,
FieldValues,
UseFormSetError,
} from "react-hook-form";
interface BackendFieldErrors {
[fieldName: string]:
| string
| undefined;
}
interface MapErrorsOptions<
TValues extends FieldValues,
> {
fieldErrors:
BackendFieldErrors;
allowedFields:
readonly FieldPath<
TValues
>[];
setError:
UseFormSetError<
TValues
>;
}
export function mapBackendFieldErrors<
TValues extends FieldValues,
>({
fieldErrors,
allowedFields,
setError,
}: MapErrorsOptions<TValues>) {
const allowedFieldSet =
new Set<string>(
allowedFields,
);
let firstField:
| FieldPath<TValues>
| undefined;
for (const [
fieldName,
message,
] of Object.entries(
fieldErrors,
)) {
if (
!message ||
!allowedFieldSet.has(
fieldName,
)
) {
continue;
}
const typedField =
fieldName as FieldPath<
TValues
>;
firstField ??=
typedField;
setError(
typedField,
{
type: "server",
message,
},
{
shouldFocus:
firstField ===
typedField,
},
);
}
}
Usage:
mapBackendFieldErrors({
fieldErrors:
apiError.fieldErrors,
allowedFields: [
"username",
"displayName",
"email",
"password",
"acceptTerms",
] as const,
setError,
});
Should server errors disappear while typing?
Suppose the server says:
Email already exists.
Then the user changes the email.
The old server error may no longer be relevant.
You may clear it:
{...register("email", {
onChange: () => {
if (
errors.email?.type ===
"server"
) {
clearErrors("email");
}
},
})}
This approach clears only backend errors.
It does not blindly clear client-validation errors.
A more advanced strategy is:
Server error exists
↓
User edits field
↓
Clear stale server error
↓
Run schema validation again
Do not leave “Email already exists” visible after the user has entered a completely different email.
Avoid clearing all errors on every keystroke
Bad:
onChange={() => {
clearErrors();
}}
This may remove errors from unrelated fields and create a misleading form state.
Better:
onChange={() => {
clearErrors("email");
}}
Clear only the stale error associated with the changed value.
Field errors versus root errors
Use field errors when the user can correct a specific input.
Email already exists
→ email field
Username already taken
→ username field
Password violates policy
→ password field
Use root errors when no single input is responsible.
Server unavailable
Session expired
Too many requests
Payment service unavailable
Unexpected failure
Example:
setError(
"root.server",
{
type:
"SERVICE_UNAVAILABLE",
message:
"Registration is temporarily unavailable.",
},
);
Do not trust backend field names blindly
This is risky:
setError(
apiError.field as keyof RegisterInput,
{
message:
apiError.message,
},
);
The type assertion tells TypeScript to trust an unverified network value.
Instead:
if (
isRegisterFieldName(
apiError.field,
)
) {
setError(
apiError.field,
{
type: "server",
message:
apiError.message,
},
);
}
Runtime data needs runtime validation.
Advantages of structured backend-error mapping
- Errors appear beside relevant fields
- Users know what to correct
- The API remains frontend-independent
- Error codes support localization
- Multiple field errors can be displayed
- Root errors remain separate
- TypeScript protects known field names
- Focus can move to the first invalid field
Disadvantages and complexity
- Backend and frontend must agree on error shapes
- Field paths must remain stable
- Network responses must be validated
- Different forms need different allowed-field lists
- Some errors become stale after editing
- Localization requires message or code design
- Nested field paths require careful handling
Common beginner mistakes
Displaying every backend error as a toast
A toast such as:
Validation failed
may disappear before the user corrects the field.
Field errors should usually remain near the relevant control.
Use a toast for broader events such as:
Profile saved successfully.
Putting every error under email
Bad:
catch (error) {
setError("email", {
message:
"Something went wrong.",
});
}
A network failure is not an email error.
Comparing error messages
Bad:
if (
message ===
"Email already exists"
) {
// ...
}
Use a stable code or field property.
Assuming Axios errors always contain a response
For network failures:
error.response
may be undefined.
Handle:
Server response error
Network/no-response error
Request configuration error
Unknown programming error
separately.
Interview questions
How does setError("email") create errors.email?
React Hook Form uses field names as keys or paths. Calling setError("email", ...) places an error at the email path in the form's error state, which is exposed as formState.errors.email.
Why does the error appear only under the email input?
Only the email component renders errors.email. Other fields render their own error paths, such as errors.username or errors.password.
What is the difference between a field error and a root error?
A field error belongs to a specific user-editable value. A root error represents a form-wide, network, session, or server problem.
Why should backend field names be checked at runtime?
Network responses are untrusted runtime data. A TypeScript type or assertion cannot guarantee that the server actually returned a valid form-field name.
Why this evolved
Mapping backend errors solved the data-flow problem, but displaying an error visually is not enough. Users navigate forms using keyboards, screen readers, zoom, speech input, and many other assistive technologies. Modern forms therefore need explicit semantic and accessibility architecture.
Stage 15: Accessible Production Forms
Accessibility is not an optional layer added after the form works.
The form's structure determines whether users can:
- Discover fields
- Understand instructions
- Identify errors
- Navigate with a keyboard
- Complete the form with a screen reader
- Recover from failed submission
- Understand loading and success states
The W3C recommends properly associated labels, clear instructions, accessible validation messages, and server-side validation in addition to client-side validation.
Accessible form model
A field usually needs four relationships.
Field
├── Accessible name
├── Instructions
├── Current validity
└── Error description
Example:
<label for="password">
Password
</label>
<input
id="password"
aria-describedby="
password-hint
password-error
"
aria-invalid="true"
/>
<p id="password-hint">
Use at least eight characters.
</p>
<p id="password-error">
Password must include a number.
</p>
1. Labels
Every input should have an accessible name.
The most reliable approach for ordinary form controls is:
<label for="email">
Email
</label>
<input
id="email"
type="email"
/>
The connection is:
label for="email"
↓
input id="email"
W3C guidance states that labels should describe the purpose of their associated controls.
Why labels matter
A visible label helps:
- Sighted users
- Screen-reader users
- Speech-input users
- Users with memory or attention difficulties
- Users reviewing previously entered data
Clicking a correctly associated label also focuses or activates the control.
Do not use placeholder as the only label
Bad:
<input
type="email"
placeholder="Email"
/>
Problems:
- Placeholder disappears while typing
- It may have low contrast
- It does not consistently provide the same labeling behavior
- Users may confuse examples with entered values
- It becomes difficult to review the form
Better:
<label for="email">
Email
</label>
<input
id="email"
type="email"
placeholder="name@example.com"
/>
The label explains the field.
The placeholder provides an optional example.
2. id and htmlFor
In React, HTML's for attribute becomes:
htmlFor
Example:
<label htmlFor="email">
Email
</label>
<input id="email" />
The values must match exactly.
Bad:
<label htmlFor="userEmail">
Email
</label>
<input id="email" />
The label is not associated with the input.
3. Instructions and hints
Fields often require more than a short label.
Example:
<label htmlFor="username">
Username
</label>
<input
id="username"
aria-describedby="username-hint"
/>
<p id="username-hint">
Use 3–20 letters, numbers,
or underscores.
</p>
aria-describedby connects the control to one or more elements that provide additional descriptive information.
Multiple descriptions
An input can reference multiple IDs separated by spaces.
<input
id="password"
aria-describedby={
errors.password
? "password-hint password-error"
: "password-hint"
}
/>
The related elements:
<p id="password-hint">
Use at least eight characters.
</p>
{errors.password && (
<p id="password-error">
Include at least one number.
</p>
)}
The accessible description can include both the instruction and the error.
4. aria-invalid
When a value has been determined to be invalid:
<input
aria-invalid="true"
/>
When it is valid or has not been marked invalid:
<input
aria-invalid="false"
/>
In React:
aria-invalid={Boolean(
errors.email,
)}
aria-invalid communicates that the application does not accept the current value. It should normally be set after validation rather than marking untouched empty fields as invalid immediately.
aria-invalid does not display an error
This:
aria-invalid="true"
does not automatically:
- Show red text
- Create a message
- Prevent submission
- Explain how to correct the value
It only communicates the state to assistive technology.
You still need:
<p id="email-error">
Enter a valid email address.
</p>
5. aria-describedby
<input
id="email"
aria-describedby="email-error"
/>
<p id="email-error">
Email already exists.
</p>
The input references the error element's id.
Input:
aria-describedby="email-error"
↓
Error:
id="email-error"
This relationship helps assistive technology associate the message with the field.
aria-errormessage
Another option is:
<input
id="email"
aria-invalid="true"
aria-errormessage="email-error"
/>
<p id="email-error">
Email already exists.
</p>
aria-errormessage explicitly identifies the element containing the error, and it should be used when the control is currently invalid.
For broad compatibility, many form implementations continue to use:
aria-describedby
for hints and errors.
Whichever approach you choose, test it with the assistive technologies important to your users.
6. Required fields
Use the native required attribute when appropriate:
<input
id="email"
type="email"
required
/>
Native required adds semantic meaning and browser behavior.
When custom validation uses:
<form noValidate>
the browser's validation popup is disabled, but the required attribute can still communicate semantics.
<input
required
{...register("email")}
/>
Also indicate required fields visually:
<label htmlFor="email">
Email
<span aria-hidden="true">
*
</span>
<span className="sr-only">
required
</span>
</label>
Do not rely only on color or a red asterisk whose meaning is never explained. Native required is preferable for semantic HTML controls; ARIA alone changes accessibility information but does not add native validation behavior.
7. Keyboard navigation
A form should be usable without a mouse.
Users should be able to:
Tab
→ Move to the next interactive control
Shift + Tab
→ Move to the previous control
Space
→ Toggle checkbox or activate selected controls
Arrow keys
→ Navigate native radio groups and select controls
Enter
→ Submit where appropriate
Use native controls whenever possible:
<button>
<input>
<select>
<textarea>
Avoid replacing them with:
<div onClick={...}>
A <div> does not automatically provide:
- Keyboard behavior
- Focus behavior
- Button semantics
- Disabled behavior
- Form submission behavior
Bad custom button
<div
onClick={submitForm}
>
Submit
</div>
A keyboard user may not be able to focus or activate it.
Better:
<button type="submit">
Submit
</button>
8. Button types
Inside a form:
<button>
defaults to submit behavior.
Use explicit types.
<button type="submit">
Create account
</button>
<button type="button">
Show password
</button>
<button type="button">
Add work experience
</button>
React's documentation warns that a button without a type inside a form submits by default, so custom button components should make their behavior explicit.
9. Focus management
After failed submission, users need to find the problem.
React Hook Form can focus a registered field:
setError(
"email",
{
type: "server",
message:
"Email already exists.",
},
{
shouldFocus: true,
},
);
For client validation, React Hook Form can also focus the first invalid registered field when configured to do so.
Error summary for large forms
For long forms, focusing only one field may not show the complete problem.
Display a summary:
interface ErrorSummaryProps {
errors: Array<{
fieldId: string;
label: string;
message: string;
}>;
}
function ErrorSummary({
errors,
}: ErrorSummaryProps) {
if (errors.length === 0) {
return null;
}
return (
<section
aria-labelledby="error-summary-title"
className="error-summary"
role="alert"
tabIndex={-1}
>
<h2 id="error-summary-title">
Correct the following
problems
</h2>
<ul>
{errors.map(
(error) => (
<li
key={
error.fieldId
}
>
<a
href={`#${error.fieldId}`}
>
{error.label}:{" "}
{error.message}
</a>
</li>
),
)}
</ul>
</section>
);
}
W3C guidance recommends that an error summary clearly identify each problem, explain how to fix it, and link to the corresponding control.
Focusing the error summary
import {
useEffect,
useRef,
} from "react";
function ErrorSummary({
errors,
}: ErrorSummaryProps) {
const summaryRef =
useRef<HTMLElement>(null);
useEffect(() => {
if (errors.length > 0) {
summaryRef.current?.focus();
}
}, [errors]);
if (errors.length === 0) {
return null;
}
return (
<section
ref={summaryRef}
role="alert"
tabIndex={-1}
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">
Correct the following
problems
</h2>
{/* Error links */}
</section>
);
}
tabIndex={-1} allows programmatic focus without adding the section to normal Tab navigation.
Do not move focus after every keystroke.
Focus changes should be deliberate and predictable.
10. Live regions
Some messages appear dynamically:
Checking username...
Username is available.
Saving...
Profile saved.
Use an appropriate live region:
<p
role="status"
aria-live="polite"
>
{statusMessage}
</p>
For an urgent failure:
<div role="alert">
Your session expired.
</div>
Use live regions sparingly.
If every change is announced, the interface becomes noisy and difficult to use.
role="status" versus role="alert"
Use status for non-urgent updates:
Uploading file...
Draft saved.
Registration complete.
Use alert for important errors requiring attention:
Payment failed.
Session expired.
Unable to save changes.
Do not apply role="alert" to every character-count update.
11. Loading state
An accessible loading state should communicate more than a spinner.
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating account..."
: "Create account"}
</button>
This provides visible text and prevents duplicate interaction.
You may also include:
<p
role="status"
aria-live="polite"
>
{isSubmitting
? "Creating your account."
: ""}
</p>
Disabled versus aria-disabled
Native:
<button disabled>
Submit
</button>
prevents interaction and usually removes the control from the normal focus sequence.
<button aria-disabled="true">
Submit
</button>
communicates the disabled state but does not automatically prevent clicking.
ARIA describes semantics; developers must still implement behavior.
For a normal submit button, prefer native:
disabled={isSubmitting}
12. Do not disable submit solely because the form is invalid
Some forms use:
<button
disabled={!isValid}
>
Submit
</button>
This can create accessibility and usability problems:
- Users may not understand why the button is disabled.
- They cannot submit to reveal missing-field errors.
- The disabled button cannot explain what remains incomplete.
- Validation may not have run yet.
A common approach is:
<button
disabled={isSubmitting}
>
Submit
</button>
Allow submission, then clearly display and focus validation errors.
There are valid exceptions, but a disabled button should never be the only explanation of invalid form state.
13. Field groups
Related checkboxes or radio buttons should be grouped.
<fieldset>
<legend>
Preferred contact method
</legend>
<label>
<input
type="radio"
value="email"
{...register(
"contactMethod",
)}
/>
Email
</label>
<label>
<input
type="radio"
value="phone"
{...register(
"contactMethod",
)}
/>
Phone
</label>
</fieldset>
fieldset and legend provide a semantic group label.
This is clearer than showing several unrelated labels without context.
14. Error text should be useful
Bad:
Invalid input.
Better:
Enter an email address in the format name@example.com.
Bad:
Password error.
Better:
Password must contain at least eight characters.
An error should answer:
What is wrong?
How can I correct it?
15. Do not rely only on color
Bad:
Red border = invalid
Green border = valid
Some users cannot reliably distinguish those colors.
Use:
- Error text
- Icons with text
aria-invalid- Clear labels
- Visible focus styles
Color can support the message, but should not be the only signal.
Complete accessible reusable input
import {
forwardRef,
} from "react";
import type {
InputHTMLAttributes,
ReactNode,
} from "react";
interface AccessibleInputProps
extends InputHTMLAttributes<HTMLInputElement> {
id: string;
label: string;
error?: string;
hint?: ReactNode;
requiredLabel?: boolean;
}
const AccessibleInput =
forwardRef<
HTMLInputElement,
AccessibleInputProps
>(function AccessibleInput(
{
id,
label,
error,
hint,
requiredLabel,
required,
...inputProps
},
ref,
) {
const hintId =
hint
? `${id}-hint`
: undefined;
const errorId =
error
? `${id}-error`
: undefined;
const describedBy = [
hintId,
errorId,
]
.filter(Boolean)
.join(" ") || undefined;
return (
<div className="field">
<label htmlFor={id}>
{label}
{requiredLabel && (
<>
<span
aria-hidden="true"
>
{" *"}
</span>
<span className="sr-only">
{" required"}
</span>
</>
)}
</label>
<input
{...inputProps}
id={id}
ref={ref}
required={required}
aria-invalid={Boolean(
error,
)}
aria-describedby={
describedBy
}
/>
{hint && (
<p
id={hintId}
className="hint"
>
{hint}
</p>
)}
{error && (
<p
id={errorId}
className="error"
>
{error}
</p>
)}
</div>
);
});
export default AccessibleInput;
Screen-reader-only CSS
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
white-space: nowrap;
border: 0;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
}
Use visually hidden content for information that assistive technology needs, not as a substitute for visible instructions that all users would benefit from.
Complete accessible React Hook Form example
import {
useMemo,
} from "react";
import {
useForm,
} from "react-hook-form";
interface ContactFormValues {
name: string;
email: string;
subject: string;
message: string;
contactMethod: "email" | "phone";
phone: string;
}
const defaultValues:
ContactFormValues = {
name: "",
email: "",
subject: "",
message: "",
contactMethod: "email",
phone: "",
};
export default function ContactForm() {
const {
register,
handleSubmit,
watch,
formState: {
errors,
isSubmitting,
},
} =
useForm<ContactFormValues>({
defaultValues,
mode: "onSubmit",
});
const contactMethod =
watch("contactMethod");
const errorSummary =
useMemo(() => {
const items: Array<{
fieldId: string;
label: string;
message: string;
}> = [];
if (errors.name?.message) {
items.push({
fieldId: "name",
label: "Name",
message:
errors.name.message,
});
}
if (errors.email?.message) {
items.push({
fieldId: "email",
label: "Email",
message:
errors.email.message,
});
}
if (
errors.subject?.message
) {
items.push({
fieldId: "subject",
label: "Subject",
message:
errors.subject.message,
});
}
if (
errors.message?.message
) {
items.push({
fieldId: "message",
label: "Message",
message:
errors.message.message,
});
}
if (errors.phone?.message) {
items.push({
fieldId: "phone",
label: "Phone",
message:
errors.phone.message,
});
}
return items;
}, [errors]);
async function onSubmit(
values: ContactFormValues,
) {
await new Promise(
(resolve) => {
setTimeout(
resolve,
1000,
);
},
);
console.log(values);
}
return (
<form
onSubmit={
handleSubmit(onSubmit)
}
noValidate
>
<h1>Contact support</h1>
<p>
Fields marked with an
asterisk are required.
</p>
<ErrorSummary
errors={errorSummary}
/>
<AccessibleInput
id="name"
label="Name"
required
requiredLabel
autoComplete="name"
error={
errors.name?.message
}
{...register("name", {
required:
"Enter your name.",
})}
/>
<AccessibleInput
id="email"
label="Email"
type="email"
required
requiredLabel
autoComplete="email"
hint="For example, name@example.com."
error={
errors.email?.message
}
{...register("email", {
required:
"Enter your email address.",
pattern: {
value:
/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message:
"Enter an email address in the format name@example.com.",
},
})}
/>
<AccessibleInput
id="subject"
label="Subject"
required
requiredLabel
error={
errors.subject?.message
}
{...register(
"subject",
{
required:
"Enter a subject.",
},
)}
/>
<div className="field">
<label htmlFor="message">
Message
<span aria-hidden="true">
{" *"}
</span>
<span className="sr-only">
{" required"}
</span>
</label>
<textarea
id="message"
rows={6}
required
aria-invalid={Boolean(
errors.message,
)}
aria-describedby={
errors.message
? "message-hint message-error"
: "message-hint"
}
{...register(
"message",
{
required:
"Enter a message.",
minLength: {
value: 20,
message:
"Your message must contain at least 20 characters.",
},
},
)}
/>
<p
id="message-hint"
className="hint"
>
Include enough information
for us to understand the
problem.
</p>
{errors.message && (
<p
id="message-error"
className="error"
>
{
errors.message
.message
}
</p>
)}
</div>
<fieldset>
<legend>
Preferred contact method
</legend>
<label>
<input
type="radio"
value="email"
{...register(
"contactMethod",
)}
/>
Email
</label>
<label>
<input
type="radio"
value="phone"
{...register(
"contactMethod",
)}
/>
Phone
</label>
</fieldset>
{contactMethod ===
"phone" && (
<AccessibleInput
id="phone"
label="Phone number"
type="tel"
required
requiredLabel
autoComplete="tel"
error={
errors.phone?.message
}
{...register(
"phone",
{
required:
"Enter your phone number.",
pattern: {
value:
/^[0-9+\-\s]{8,15}$/,
message:
"Enter a valid phone number.",
},
},
)}
/>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Sending message..."
: "Send message"}
</button>
<p
role="status"
aria-live="polite"
>
{isSubmitting
? "Your message is being sent."
: ""}
</p>
</form>
);
}
Accessibility testing checklist
Test using:
Keyboard only
├── Can every field be reached?
├── Is focus visible?
├── Is the order logical?
└── Can every action be performed?
Screen reader
├── Is each field named?
├── Are hints associated?
├── Are errors announced?
└── Are status changes understandable?
Zoom and reflow
├── Does the form work at high zoom?
├── Is horizontal scrolling avoided?
└── Does text remain readable?
Error recovery
├── Is the first problem easy to find?
├── Does each error explain the fix?
└── Are entered values preserved?
Automated tools can find some problems.
They cannot fully judge:
- Whether instructions make sense
- Whether focus movement is confusing
- Whether error text is helpful
- Whether the complete task is usable
Manual testing remains necessary.
Advantages of accessible form architecture
- Supports more users
- Improves keyboard navigation
- Improves error recovery
- Creates clearer labels and instructions
- Usually improves mobile usability
- Produces more maintainable semantics
- Reduces ambiguity for every user
- Helps forms work across different technologies
Disadvantages and costs
- Requires deliberate design
- Custom components need more work
- Dynamic announcements require testing
- Screen-reader behavior can vary
- Complex widgets require strong keyboard support
- Accessibility cannot be verified by one automated test
These are not reasons to omit accessibility.
They are reasons to include it in the original architecture instead of adding it after development.
Common beginner mistakes
Adding ARIA instead of semantic HTML
Bad:
<div
role="button"
tabIndex={0}
>
Submit
</div>
Better:
<button type="submit">
Submit
</button>
Use native semantics first.
Applying aria-invalid to every empty field immediately
The user may not have had a chance to fill it.
Show validation at a deliberate time:
- After submit
- On blur
- After the field has been touched
- During correction after a failed submit
Moving focus on every validation change
This makes typing difficult and disorienting.
Focus the first invalid control after submission or another clear workflow transition.
Removing focus outlines
Bad:
*:focus {
outline: none;
}
Users navigating with a keyboard need a visible focus indicator.
Interview questions
What does aria-invalid do?
It communicates that the current value does not meet the application's accepted format or rule. It does not display an error or prevent submission by itself.
What does aria-describedby do?
It associates a control with one or more elements that provide additional descriptive text, such as instructions or an error message.
Why are labels important?
Labels provide accessible names, make form purposes clear, and allow clicking the label to focus or activate the associated native control.
Why use fieldset and legend?
They semantically group related fields such as radio buttons or checkboxes and provide a group-level label.
Should a form rely only on red borders for errors?
No. Error state should also be communicated through text and semantic attributes because color alone is not sufficient.
Why this evolved
Accessible forms made interaction understandable across different input and assistive technologies. As forms became larger and more dynamic, teams also needed to ensure that validation and state updates did not make typing slow. This led to deeper attention to render performance and subscription-based form architecture.
Stage 16: Form Performance
Form performance discussions often become oversimplified.
You may hear:
Formik is slow.
React Hook Form is fast.
Uncontrolled inputs never rerender.
Controlled inputs are bad.
These statements are too absolute.
Actual performance depends on:
- Number of fields
- Validation mode
- Component structure
- Watched values
- Schema cost
- Controlled components
- Parent rerenders
- Dynamic arrays
- Expensive child components
- Browser and device
- Network requests
- User-visible latency
What is a React render?
When state changes, React may run a component function again.
function RegistrationForm() {
console.log(
"RegistrationForm render",
);
return (
<form>
{/* JSX */}
</form>
);
}
Running the component again is a React render.
It does not necessarily mean the browser recreates every DOM node.
The simplified flow is:
State update
↓
Component function runs
↓
New React element description
↓
React compares previous and next output
↓
Required DOM changes are committed
Controlled input render flow
const [email, setEmail] =
useState("");
<input
value={email}
onChange={(event) => {
setEmail(
event.target.value,
);
}}
/>
Each keystroke updates state.
User types "k"
↓
onChange runs
↓
setEmail("k")
↓
Form component renders
↓
Input receives value "k"
React's official documentation states that controlled inputs update state on every keystroke, and a large surrounding tree can become slow if it rerenders for each edit. It recommends isolating the input state in a smaller component where possible.
Controlled input does not automatically mean slow
This is usually fine:
function SearchBox() {
const [
query,
setQuery,
] = useState("");
return (
<input
value={query}
onChange={(event) => {
setQuery(
event.target.value,
);
}}
/>
);
}
A small component rerender is generally inexpensive.
Problems appear when the state is located too high:
function EntireDashboard() {
const [
search,
setSearch,
] = useState("");
return (
<>
<Navigation />
<LargeAnalyticsChart />
<HugeDataTable />
<ExpensiveRecommendations />
<input
value={search}
onChange={(event) => {
setSearch(
event.target.value,
);
}}
/>
</>
);
}
Now every keystroke causes EntireDashboard() to run again.
Better component boundary
function EntireDashboard() {
return (
<>
<Navigation />
<LargeAnalyticsChart />
<HugeDataTable />
<ExpensiveRecommendations />
<SearchForm />
</>
);
}
function SearchForm() {
const [
search,
setSearch,
] = useState("");
return (
<input
value={search}
onChange={(event) => {
setSearch(
event.target.value,
);
}}
/>
);
}
Only the relevant form subtree owns the rapidly changing state.
Component boundaries are often a more important optimization than replacing the complete form library.
Controlled form diagram
Email keystroke
↓
setValues()
↓
Form component renders
↓
Username JSX recalculated
Email JSX recalculated
Password JSX recalculated
Buttons recalculated
Debug panel recalculated
↓
React commits required DOM update
“JSX recalculated” does not mean that every DOM node changed.
But the component function and its non-memoized child calls may still perform work.
Formik-style central state
A form library using central React state may broadly follow:
Field changes
↓
Central form state changes
↓
Form context/provider updates
↓
Subscribed or consuming
components render
Performance depends on:
- Which state each field consumes
- Whether optimized field components are used
- Context boundaries
- Memoization
- Validation cost
Formik can be entirely suitable for many applications.
Architecture should be evaluated using the real form, not stereotypes.
React Hook Form registration flow
<input
{...register("email")}
/>
React Hook Form registers the input for value tracking, validation, and submission.
Conceptually:
User types
↓
Native input value changes
↓
Registered event handler runs
↓
Internal field state updates
↓
Subscribed form state may update
↓
Relevant consumers rerender
The parent does not necessarily need a React-state update solely to place the latest character into the native input.
React Hook Form formState subscriptions
Suppose a component reads:
const {
formState: {
errors,
},
} = useForm();
It consumes errors.
If it reads:
const {
formState: {
errors,
isDirty,
isValid,
isSubmitting,
},
} = useForm();
it consumes more form-state properties.
React Hook Form documents that formState is wrapped in a Proxy so it can avoid work for state properties that have not been subscribed to.
Important destructuring pattern
Prefer reading needed state during render:
const {
formState: {
isDirty,
errors,
},
} = useForm();
Avoid treating the complete object as an ordinary mutable object and expecting arbitrary later reads to establish optimal subscriptions.
Think explicitly about which state the component needs.
React Hook Form does rerender
React Hook Form may rerender when:
- A validation error changes
-
isDirtychanges -
isValidchanges -
isSubmittingchanges -
touchedFieldschanges -
watch()observes a changed value -
Controllerupdates a controlled field - A field array changes
- The parent component rerenders
The realistic claim is:
React Hook Form can avoid requiring a parent React-state update for every native input keystroke and can limit updates through subscriptions.
Not:
React Hook Form never rerenders.
Render comparison: three fields
Suppose a user types ten characters into the email field.
Simple controlled parent
Approximate component executions:
Initial render: 1
Ten state updates: 10
Total: about 11 form renders
Registered uncontrolled input
Possible flow:
Initial render: 1
First dirty-state change: render
Validation error changes: render when applicable
Submission-state changes: later renders
The exact number depends on:
- Validation mode
- State consumed
- Watched values
- React development mode
- Component structure
- Library version
Do not publish benchmark numbers without measuring the actual implementation.
Development mode can confuse render counting
React development features may intentionally run components or setup logic more than once to identify unsafe behavior.
Do not conclude that production performance is broken solely because:
console.log("render");
appears multiple times in development.
Measure production builds and user-visible interaction.
Performance problem 1: Watching the entire form
const values = watch();
Now the component depends on every registered value.
Any field changes
↓
watch() value changes
↓
Component rerenders
This may be reasonable for:
- Live JSON preview
- Small form builder
- Form-debugging tools
It is unnecessary when only one conditional field depends on the country.
Better:
const country =
watch("country");
Isolate watched values
function StateField({
control,
}: {
control:
Control<AddressFormValues>;
}) {
const country =
useWatch({
control,
name: "country",
});
if (country !== "india") {
return null;
}
return (
<input
{...register("state")}
/>
);
}
In a real implementation, pass or retrieve the required registration methods appropriately.
The key idea is:
Only the conditional section
subscribes to country
instead of making the complete parent form depend on it.
Performance problem 2: Validating on every keystroke
useForm({
mode: "onChange",
resolver:
zodResolver(
largeSchema,
),
});
This can run validation frequently.
For a small form, that may be fine.
For a large schema containing:
- Complex refinements
- Large arrays
- Expensive transformations
- Async validation
- Many cross-field checks
it can become noticeable.
Choose validation timing deliberately
onSubmit
useForm({
mode: "onSubmit",
});
Advantages:
- Lowest validation frequency
- Less interruption while typing
- Good for simple forms
Disadvantage:
- Feedback arrives after submission
onBlur
useForm({
mode: "onBlur",
});
Advantages:
- Balanced feedback
- Avoids checking every character
- User finishes a field before seeing most errors
Disadvantage:
- Some users may not notice errors until leaving fields
onChange
useForm({
mode: "onChange",
});
Advantages:
- Immediate feedback
-
isValidcan update continuously
Disadvantages:
- More validation runs
- Errors may appear too early
- Potential performance cost
Practical pattern
A common production strategy is:
Before first submit
→ Validate on submit or blur
After an error appears
→ Revalidate that field while editing
This gives useful correction feedback without aggressively validating untouched fields.
Performance problem 3: Expensive asynchronous validation
Bad:
register("username", {
validate: async (username) => {
const response =
await api.get(
`/users/check/${username}`,
);
return (
response.data.available ||
"Username is taken."
);
},
});
With mode: "onChange", this might send a request for:
k
ka
kar
kart
karth
karthi
karthik
Problems:
- Too many requests
- Increased server load
- Out-of-order responses
- Flickering validation
- Rate-limit consumption
- Slower typing experience
Better asynchronous validation
Use local checks first:
Empty?
Too short?
Invalid characters?
↓
Only then consider
availability request
Debounce the remote request and cancel stale requests.
import {
useEffect,
useState,
} from "react";
function useUsernameAvailability(
username: string,
) {
const [
status,
setStatus,
] = useState<
| "idle"
| "checking"
| "available"
| "unavailable"
| "error"
>("idle");
useEffect(() => {
const normalized =
username.trim();
if (
normalized.length < 3
) {
setStatus("idle");
return;
}
const controller =
new AbortController();
const timeoutId =
window.setTimeout(
async () => {
setStatus(
"checking",
);
try {
const response =
await fetch(
`/api/users/username-availability?username=${encodeURIComponent(
normalized,
)}`,
{
signal:
controller.signal,
},
);
if (!response.ok) {
throw new Error(
"Request failed",
);
}
const result =
(await response.json()) as {
available: boolean;
};
setStatus(
result.available
? "available"
: "unavailable",
);
} catch (error) {
if (
error instanceof
DOMException &&
error.name ===
"AbortError"
) {
return;
}
setStatus("error");
}
},
400,
);
return () => {
window.clearTimeout(
timeoutId,
);
controller.abort();
};
}, [username]);
return status;
}
The authoritative uniqueness check must still occur during backend submission.
Availability checks improve feedback.
They do not reserve the username.
Performance problem 4: Large parent components
Bad:
function ApplicationPage() {
const form =
useForm<ApplicationValues>();
const allValues =
form.watch();
return (
<>
<ExpensiveHeader />
<LargeResumePreview
values={allValues}
/>
<ApplicationForm
form={form}
/>
<RecommendationEngine
values={allValues}
/>
</>
);
}
Every field may cause multiple expensive sections to update.
Solutions include:
- Smaller component boundaries
- Narrow subscriptions
- Debounced preview updates
-
useDeferredValuewhere suitable - Memoized expensive components
- Rendering previews only when required
Performance problem 5: Passing unstable props
Consider:
<ProfileInput
options={{
required: true,
}}
/>
The object is recreated on every render.
For a memoized child, unstable references may defeat memoization.
This matters only when profiling shows a real issue.
Do not add useMemo() everywhere automatically.
Premature memoization can make code harder to understand without improving user experience.
Performance problem 6: Controlled third-party components
Some components require:
<Controller
name="country"
control={control}
render={({ field }) => (
<CustomSelect
value={field.value}
onChange={field.onChange}
/>
)}
/>
This field is controlled.
Using Controller does not make the component uncontrolled.
React Hook Form
↓
Controller
↓
Controlled custom component
Performance still depends on the controlled component's implementation and subscription boundaries.
Use register() for native inputs where practical.
Use Controller where controlled integration is genuinely required.
Performance problem 7: Rendering complete error objects
Avoid passing the entire form state to every field:
<Input
formState={formState}
name="email"
/>
Now every input may depend on a large changing object.
Prefer narrow props:
<Input
error={
errors.email?.message
}
/>
Or use field-level subscription APIs when the component architecture requires them.
Performance problem 8: Giant schemas
A single schema may validate:
User profile
Billing address
Shipping address
Company
Tax details
Twenty work experiences
Fifty uploaded files
Consider splitting validation by workflow or step.
const personalSchema =
z.object({
firstName:
z.string().min(1),
lastName:
z.string().min(1),
});
const addressSchema =
z.object({
country:
z.string().min(1),
city:
z.string().min(1),
});
const completeSchema =
z.object({
personal:
personalSchema,
address:
addressSchema,
});
A multi-step form may validate only the current section before moving forward.
Measuring rerenders
Create a helper:
import {
useRef,
} from "react";
function RenderCounter({
name,
}: {
name: string;
}) {
const renderCount =
useRef(0);
renderCount.current += 1;
return (
<small>
{name} renders:{" "}
{renderCount.current}
</small>
);
}
Use during development:
function EmailSection() {
return (
<div>
<RenderCounter
name="EmailSection"
/>
{/* Email input */}
</div>
);
}
This is a debugging aid, not a production benchmark.
Use the React Profiler
For meaningful investigation:
- Record the interaction.
- Type in the slow field.
- Identify which components rendered.
- Find expensive renders.
- Inspect why their props or state changed.
- Optimize the actual bottleneck.
- Measure again.
Do not optimize based only on assumptions.
Complete controlled-form comparison
import {
useState,
} from "react";
interface Values {
firstName: string;
lastName: string;
email: string;
}
export function ControlledForm() {
const [
values,
setValues,
] = useState<Values>({
firstName: "",
lastName: "",
email: "",
});
console.log(
"Controlled form render",
);
function update(
field: keyof Values,
value: string,
) {
setValues(
(currentValues) => ({
...currentValues,
[field]: value,
}),
);
}
return (
<form>
<input
value={
values.firstName
}
onChange={(event) => {
update(
"firstName",
event.target.value,
);
}}
/>
<input
value={
values.lastName
}
onChange={(event) => {
update(
"lastName",
event.target.value,
);
}}
/>
<input
value={values.email}
onChange={(event) => {
update(
"email",
event.target.value,
);
}}
/>
</form>
);
}
Every field change calls:
setValues()
which schedules a render of ControlledForm.
Complete React Hook Form comparison
import {
useForm,
} from "react-hook-form";
interface Values {
firstName: string;
lastName: string;
email: string;
}
export function HookForm() {
const {
register,
handleSubmit,
formState: {
errors,
isDirty,
},
} = useForm<Values>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
},
});
console.log(
"Hook form render",
);
return (
<form
onSubmit={handleSubmit(
console.log,
)}
>
<input
{...register(
"firstName",
{
required:
"First name is required.",
},
)}
/>
<input
{...register(
"lastName",
{
required:
"Last name is required.",
},
)}
/>
<input
type="email"
{...register(
"email",
{
required:
"Email is required.",
},
)}
/>
{errors.email && (
<p>
{errors.email.message}
</p>
)}
<p>
Dirty:{" "}
{String(isDirty)}
</p>
<button type="submit">
Submit
</button>
</form>
);
}
This form subscribes to:
errors
isDirty
Changing from pristine to dirty may cause a render.
Changing an error may cause a render.
But every later character does not necessarily require parent state to be updated merely to display the input value.
Simplified render diagrams
Controlled parent form
Character entered
↓
React state update
↓
Parent form render
↓
Children evaluated
↓
DOM reconciliation
React Hook Form native input
Character entered
↓
Native DOM value changes
↓
RHF handler updates
internal field state
↓
Relevant subscribed state changed?
/ \
No Yes
↓ ↓
No parent render Consumer render
required for value
Formik versus controlled React versus React Hook Form
| Area | Manual controlled form | Formik-style form | React Hook Form |
|---|---|---|---|
| Native input value | React state | Central form state | Usually native DOM |
| Change wiring | Manual | Library handlers | register() |
| Touched state | Manual | Built in | Built in |
| Dirty state | Manual | Built in | Built in |
| Validation | Manual | Library/schema | Rules/resolver |
| Rerender strategy | State and component boundaries | Central state plus optimizations | Registration and subscriptions |
| Controlled widgets | Natural | Natural | Usually Controller
|
| Native input boilerplate | Higher | Medium | Lower |
| Debugging values | Direct React state | Central state | Library methods/subscriptions |
This is a conceptual comparison, not a universal performance ranking.
When performance optimization matters
Optimize when users experience:
- Delayed typing
- Input cursor lag
- Slow dropdown opening
- Long validation pauses
- Noticeable step transitions
- Freezing during dynamic-field changes
- Excessive network requests
- Large delays before error messages
Do not optimize because a console printed ten render messages in a tiny form.
User-visible responsiveness is the real goal.
Senior engineer performance checklist
1. Keep rapidly changing state
close to the component that needs it.
2. Subscribe only to values
required for rendering.
3. Avoid watch() on the complete
form unless necessary.
4. Select validation timing
based on UX and schema cost.
5. Debounce and cancel
asynchronous checks.
6. Use native registered inputs
where they fit.
7. Split large forms into
logical components or steps.
8. Profile before memoizing.
9. Measure production builds.
10. Optimize user-visible delays,
not theoretical render counts.
Advantages of React Hook Form performance architecture
- Native inputs can retain their values
- Parent state is not required for every keystroke
- Form state can be subscription-based
- Field-level APIs support isolation
- Large native-input forms can use less React state wiring
- Validation timing is configurable
- Controlled components remain supported when needed
Disadvantages and trade-offs
- Architecture is less obvious than plain
useState - Broad
watch()usage can reduce benefits - Controlled components still rerender
- Resolver validation can still be expensive
- Incorrect component structure can create unnecessary renders
- Performance claims require measurement
- Debugging uncontrolled values may initially feel unfamiliar
Common beginner mistakes
Believing every render is a bug
Renders are a normal part of React.
The question is:
Is the render causing
noticeable unnecessary work?
Wrapping everything in React.memo()
Memoization has a cost and adds complexity.
Use it where profiling shows repeated expensive renders with stable inputs.
Using watch() as a replacement for getValues()
Use:
getValues()
when you only need to read a value during a button click.
Use:
watch()
when rendering must react to changes.
Running remote validation on every character
Debounce, cancel stale requests, and perform the final authoritative check during submission.
Choosing a form library only from benchmark charts
Also evaluate:
- Team knowledge
- TypeScript support
- Accessibility patterns
- Validation integration
- Design-system compatibility
- Dynamic arrays
- Maintenance
- Documentation
- Testing
- Migration cost
Interview questions
Why can controlled inputs become expensive?
They update React state on every keystroke. If that state is owned by a component rendering a large tree, the tree may perform unnecessary work after each edit.
Does a React rerender recreate the complete DOM?
No. React recalculates component output and then commits the required DOM changes. However, component functions and child calculations may still perform work.
Why can React Hook Form reduce rerenders?
Native inputs can retain their current values, while React Hook Form tracks form state through registration and subscriptions. The parent does not always need to store every character in React state.
Can React Hook Form still rerender frequently?
Yes. Errors, dirty state, validity, submission state, watched values, controlled components, field arrays, and parent updates can all cause renders.
What is the difference between watch() and getValues() from a performance perspective?
watch() subscribes rendering to value changes. getValues() reads the current value without creating the same reactive subscription.
Should mode: "onChange" always be avoided?
No. It can provide useful immediate feedback. Its cost and user experience should be evaluated based on the size and complexity of the form.
Why this evolved
React Hook Form and subscription-based state management improved the performance of many large forms. However, production systems still need to handle workflows that go beyond one page of inputs: multi-step forms, conditional sections, repeatable arrays, file uploads, autosave, and draft recovery. Those requirements lead to enterprise form architectures.
Part 4 Summary
We started by recognizing that the frontend is not a trusted boundary.
React validation
↓
Improves user experience
Express validation
↓
Protects application logic
Database constraints
↓
Protect data integrity
The backend validates:
Structure
Business rules
Database state
Authorization
Tokens and sessions
It then sends a consistent error response:
{
"success": false,
"code": "EMAIL_ALREADY_EXISTS",
"field": "email",
"message": "An account with this email already exists."
}
The frontend maps that response:
Axios catch
↓
error.response.data
↓
field = "email"
↓
setError("email")
↓
errors.email
↓
Email error paragraph
Accessibility then connects the visible interface to semantic relationships:
label
↓
input
↓
aria-invalid
↓
aria-describedby
↓
error message
Finally, performance depends on architecture rather than slogans.
Controlled input
→ React state on every change
Registered native input
→ DOM retains value
→ Form state updates through subscriptions
The central lessons are:
- Frontend validation is for experience; backend validation is for trust.
- Error responses should use consistent codes, fields, and messages.
-
setError()uses registered field paths to create errors such aserrors.email. - An error appears only where the UI explicitly renders that error path.
- Accessibility requires labels, instructions, keyboard behavior, focus management, and understandable errors.
- Rerenders are normal; optimize measured user-visible bottlenecks.
- React Hook Form can reduce value-driven renders, but subscriptions and component architecture still matter.
The next part will cover:
Stage 17
Enterprise forms
├── Multi-step forms
├── Dynamic arrays
├── Conditional fields
├── File uploads
├── Debounced validation
├── Autosave
└── Draft recovery
Stage 18
Modern form architecture
├── Folder structure
├── Reusable components
├── Schema organization
├── API layers
├── Toasts
└── Error boundaries
Stage 19
The future of forms
├── Server Actions
├── AI-assisted completion
├── Voice filling
├── OCR
├── Conversational forms
└── Agentic form completion
Top comments (0)