DEV Community

Karthik Reddy
Karthik Reddy

Posted on

The Evolution of Web Forms — Part 4

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Server-side validation with Express and Zod
  2. Mapping backend errors into React Hook Form
  3. Building accessible production forms
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Your frontend may correctly reject:

{
  "username": "",
  "email": "hello",
  "password": "12"
}
Enter fullscreen mode Exit fullscreen mode

But someone can send this object directly to your API:

POST /api/auth/register
Content-Type: application/json

{
  "username": "",
  "email": "hello",
  "password": "12"
}
Enter fullscreen mode Exit fullscreen mode

Your React application is not involved.

The request goes directly to Express.

Custom API client
       ↓
POST /api/auth/register
       ↓
Express server
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create:

packages/validation/auth.schemas.ts
Enter fullscreen mode Exit fullscreen mode
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
>;
Enter fullscreen mode Exit fullscreen mode

The frontend can use this schema through zodResolver().

The backend can use the same schema through safeParse().

                   registerSchema
                    /           \
                   /             \
          React frontend       Express backend
          zodResolver()        safeParse()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

exists, the actual runtime value may be anything.

null
Enter fullscreen mode Exit fullscreen mode
[]
Enter fullscreen mode Exit fullscreen mode
{
  "email": 123
}
Enter fullscreen mode Exit fullscreen mode
{
  "unexpected": "value"
}
Enter fullscreen mode Exit fullscreen mode

Use:

const result = registerSchema.safeParse(
  request.body,
);
Enter fullscreen mode Exit fullscreen mode

The result has two possible forms.

Success
├── success: true
└── data: validated and transformed values

Failure
├── success: false
└── error: validation details
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Example:

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

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."
) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Better:

if (
  error.code ===
  "EMAIL_ALREADY_EXISTS"
) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install the dependencies:

npm install express zod bcrypt
npm install --save-dev typescript tsx \
  @types/express @types/node @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

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
>;
Enter fullscreen mode Exit fullscreen mode

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,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  };
}
Enter fullscreen mode Exit fullscreen mode

The middleware replaces:

request.body
Enter fullscreen mode Exit fullscreen mode

with:

result.data
Enter fullscreen mode Exit fullscreen mode

That matters because Zod may normalize the values.

For example:

"  USER@EXAMPLE.COM "
          ↓
"user@example.com"
Enter fullscreen mode Exit fullscreen mode

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.",
  },
]
Enter fullscreen mode Exit fullscreen mode

The frontend usually wants:

{
  email:
    "Enter a valid email address.",

  password:
    "Password must contain at least 8 characters.",
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

Why the service checks uniqueness

Zod can validate:

email has a valid structure
Enter fullscreen mode Exit fullscreen mode

Zod cannot determine:

email is absent from the database
Enter fullscreen mode Exit fullscreen mode

The database or repository must answer that question.

Schema validation
        ↓
Email format is valid
        ↓
Repository query
        ↓
Does email already exist?
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The database should enforce uniqueness.

Conceptually:

CREATE UNIQUE INDEX users_email_unique
ON users (email);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.",
    });
  };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that the response does not return:

password
hashedPassword
Enter fullscreen mode Exit fullscreen mode

Sensitive internal values should not be included in the public response.


Structural validation response

Request:

{
  "username": "a",
  "displayName": "",
  "email": "invalid-email",
  "password": "12"
}
Enter fullscreen mode Exit fullscreen mode

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."
  }
}
Enter fullscreen mode Exit fullscreen mode

Duplicate email response

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

Username already taken response

{
  "success": false,
  "code": "USERNAME_ALREADY_EXISTS",
  "field": "username",
  "message": "This username is already taken."
}
Enter fullscreen mode Exit fullscreen mode

Invalid token validation

Not every server error belongs to a normal text field.

Suppose the user visits:

/verify-email?token=abc
Enter fullscreen mode Exit fullscreen mode

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.",
    });
  },
);
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

The frontend should not inject that into:

errors.email
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

The user can change it to:

admin
Enter fullscreen mode Exit fullscreen mode

The backend should decide privileged values itself.

Bad:

const user = await createUser({
  ...request.body,
});
Enter fullscreen mode Exit fullscreen mode

Better:

const user = await createUser({
  username:
    request.body.username,
  email:
    request.body.email,
  role: "user",
});
Enter fullscreen mode Exit fullscreen mode

Never return password hashes

Bad:

{
  "user": {
    "email": "user@example.com",
    "hashedPassword": "$2b$..."
  }
}
Enter fullscreen mode Exit fullscreen mode

Password hashes are sensitive internal data.


Do not reveal internal errors

Bad:

{
  "message": "Prisma P2002 in src/repositories/user.repository.ts line 41"
}
Enter fullscreen mode Exit fullscreen mode

Better:

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

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

This is a TypeScript assertion, not runtime validation.

Better:

const result =
  registerSchema.safeParse(
    request.body,
  );
Enter fullscreen mode Exit fullscreen mode

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."
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  },
);
Enter fullscreen mode Exit fullscreen mode

Prefer layers:

Route
→ Middleware
→ Service
→ Repository
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The backend discovers:

Email already exists
Enter fullscreen mode Exit fullscreen mode

The backend responds:

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

The frontend must:

  1. Catch the failed HTTP request.
  2. Read the response body.
  3. Recognize that the error belongs to email.
  4. Insert the error into React Hook Form.
  5. Display it under the email input.
  6. 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
Enter fullscreen mode Exit fullscreen mode

Field error

{
  "code": "EMAIL_ALREADY_EXISTS",
  "field": "email",
  "message": "Email already exists."
}
Enter fullscreen mode Exit fullscreen mode

Use:

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

Multiple field errors

{
  "code": "VALIDATION_ERROR",
  "message": "Please correct the invalid fields.",
  "fieldErrors": {
    "email": "Enter a valid email address.",
    "password": "Password is too short."
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

Use:

setError("root.server", {
  type: "server",
  message:
    "Your account was created, but the verification email could not be sent.",
});
Enter fullscreen mode Exit fullscreen mode

Authentication or navigation error

{
  "code": "SESSION_EXPIRED",
  "message": "Your session has expired."
}
Enter fullscreen mode Exit fullscreen mode

Possible handling:

clearSession();
router.push("/login");
Enter fullscreen mode Exit fullscreen mode

Not every API error should become a field error.


Complete TypeScript error contract

Create:

src/features/auth/auth.types.ts
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

Why is field typed as string?

You may be tempted to write:

field?: RegisterFieldName;
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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,
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

if (
  isRegisterFieldName(
    response.field,
  )
) {
  setError(
    response.field,
    {
      type: "server",
      message:
        response.message,
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create:

src/lib/api.ts
Enter fullscreen mode Exit fullscreen mode
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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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;
}
Enter fullscreen mode Exit fullscreen mode

The API function does not contain form-specific behavior.

It does not call:

setError()
Enter fullscreen mode Exit fullscreen mode

That belongs to the form layer.

API layer
→ Sends and receives HTTP data

Form layer
→ Decides how errors affect the UI
Enter fullscreen mode Exit fullscreen mode

Complete React Hook Form integration

Create:

src/features/auth/RegisterForm.tsx
Enter fullscreen mode Exit fullscreen mode
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step-by-step flow for a duplicate email

Suppose the user enters:

existing@example.com
Enter fullscreen mode Exit fullscreen mode

Step 1: React Hook Form collects the values

{
  username: "karthik_2005",
  displayName: "Karthik",
  email: "existing@example.com",
  password: "Password123",
  acceptTerms: true
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Zod validates the frontend data

The email has a valid format, so frontend validation passes.

existing@example.com
        ↓
Valid email structure
Enter fullscreen mode Exit fullscreen mode

Frontend validation cannot know whether it already exists in the database.


Step 3: registerUser() sends the request

await api.post(
  "/auth/register",
  values,
);
Enter fullscreen mode Exit fullscreen mode

Step 4: Express validates the request again

registerSchema.safeParse(
  request.body,
);
Enter fullscreen mode Exit fullscreen mode

The structure is valid.


Step 5: The service queries the database

const existingUser =
  await findUserByEmail(
    input.email,
  );
Enter fullscreen mode Exit fullscreen mode

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",
  },
);
Enter fullscreen mode Exit fullscreen mode

Step 7: Error middleware sends JSON

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

Step 8: Axios rejects the promise

The request returned 409 Conflict.

Execution moves into:

catch (error) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Step 9: isAxiosError() narrows the error

if (
  axios.isAxiosError<
    ApiErrorResponse
  >(error)
) {
  // Axios-specific properties
  // are safely available.
}
Enter fullscreen mode Exit fullscreen mode

Now we can access:

error.response?.data
Enter fullscreen mode Exit fullscreen mode

Step 10: The frontend reads the field name

const apiError =
  error.response.data;

apiError.field;
// "email"
Enter fullscreen mode Exit fullscreen mode

Step 11: The runtime guard verifies the field

isRegisterFieldName(
  apiError.field,
);
Enter fullscreen mode Exit fullscreen mode

Result:

true
Enter fullscreen mode Exit fullscreen mode

Step 12: setError() inserts the error

setError(
  "email",
  {
    type: "server",
    message:
      "An account with this email already exists.",
  },
  {
    shouldFocus: true,
  },
);
Enter fullscreen mode Exit fullscreen mode

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."
  }
}
Enter fullscreen mode Exit fullscreen mode

This is exposed through:

formState.errors
Enter fullscreen mode Exit fullscreen mode

Therefore:

errors.email
Enter fullscreen mode Exit fullscreen mode

contains the email error.


Step 14: Only the email UI reads errors.email

The email input contains:

aria-invalid={Boolean(
  errors.email,
)}
Enter fullscreen mode Exit fullscreen mode

The email error paragraph contains:

{errors.email && (
  <p id="email-error">
    {errors.email.message}
  </p>
)}
Enter fullscreen mode Exit fullscreen mode

The username input reads:

errors.username
Enter fullscreen mode Exit fullscreen mode

The password input reads:

errors.password
Enter fullscreen mode Exit fullscreen mode

They do not read:

errors.email
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Field path: "email"
        ↓
Registered input reference
        ↓
Value, validation, errors,
dirty state, touched state
Enter fullscreen mode Exit fullscreen mode

Then:

setError("email", ...)
Enter fullscreen mode Exit fullscreen mode

uses the same field path.

register("email")
        ↕
setError("email")
        ↕
errors.email
Enter fullscreen mode Exit fullscreen mode

The string "email" is the shared key.


Nested backend errors

Suppose the form contains:

{
  address: {
    city: "",
  },
}
Enter fullscreen mode Exit fullscreen mode

The input is registered as:

register("address.city")
Enter fullscreen mode Exit fullscreen mode

The backend may return:

{
  "field": "address.city",
  "message": "This city is not supported."
}
Enter fullscreen mode Exit fullscreen mode

The frontend can call:

setError(
  "address.city",
  {
    type: "server",
    message:
      "This city is not supported.",
  },
);
Enter fullscreen mode Exit fullscreen mode

The error becomes available through:

errors.address?.city
Enter fullscreen mode Exit fullscreen mode

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,
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

mapBackendFieldErrors({
  fieldErrors:
    apiError.fieldErrors,

  allowedFields: [
    "username",
    "displayName",
    "email",
    "password",
    "acceptTerms",
  ] as const,

  setError,
});
Enter fullscreen mode Exit fullscreen mode

Should server errors disappear while typing?

Suppose the server says:

Email already exists.
Enter fullscreen mode Exit fullscreen mode

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");
    }
  },
})}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}}
Enter fullscreen mode Exit fullscreen mode

This may remove errors from unrelated fields and create a misleading form state.

Better:

onChange={() => {
  clearErrors("email");
}}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use root errors when no single input is responsible.

Server unavailable
Session expired
Too many requests
Payment service unavailable
Unexpected failure
Enter fullscreen mode Exit fullscreen mode

Example:

setError(
  "root.server",
  {
    type:
      "SERVICE_UNAVAILABLE",
    message:
      "Registration is temporarily unavailable.",
  },
);
Enter fullscreen mode Exit fullscreen mode

Do not trust backend field names blindly

This is risky:

setError(
  apiError.field as keyof RegisterInput,
  {
    message:
      apiError.message,
  },
);
Enter fullscreen mode Exit fullscreen mode

The type assertion tells TypeScript to trust an unverified network value.

Instead:

if (
  isRegisterFieldName(
    apiError.field,
  )
) {
  setError(
    apiError.field,
    {
      type: "server",
      message:
        apiError.message,
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Putting every error under email

Bad:

catch (error) {
  setError("email", {
    message:
      "Something went wrong.",
  });
}
Enter fullscreen mode Exit fullscreen mode

A network failure is not an email error.


Comparing error messages

Bad:

if (
  message ===
  "Email already exists"
) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Use a stable code or field property.


Assuming Axios errors always contain a response

For network failures:

error.response
Enter fullscreen mode Exit fullscreen mode

may be undefined.

Handle:

Server response error
Network/no-response error
Request configuration error
Unknown programming error
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

The connection is:

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

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"
/>
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

The label explains the field.

The placeholder provides an optional example.


2. id and htmlFor

In React, HTML's for attribute becomes:

htmlFor
Enter fullscreen mode Exit fullscreen mode

Example:

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

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

The values must match exactly.

Bad:

<label htmlFor="userEmail">
  Email
</label>

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

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>
Enter fullscreen mode Exit fullscreen mode

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"
  }
/>
Enter fullscreen mode Exit fullscreen mode

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>
)}
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

When it is valid or has not been marked invalid:

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

In React:

aria-invalid={Boolean(
  errors.email,
)}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

5. aria-describedby

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

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

The input references the error element's id.

Input:
aria-describedby="email-error"
                ↓
Error:
id="email-error"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
/>
Enter fullscreen mode Exit fullscreen mode

Native required adds semantic meaning and browser behavior.

When custom validation uses:

<form noValidate>
Enter fullscreen mode Exit fullscreen mode

the browser's validation popup is disabled, but the required attribute can still communicate semantics.

<input
  required
  {...register("email")}
/>
Enter fullscreen mode Exit fullscreen mode

Also indicate required fields visually:

<label htmlFor="email">
  Email
  <span aria-hidden="true">
    *
  </span>
  <span className="sr-only">
    required
  </span>
</label>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use native controls whenever possible:

<button>
<input>
<select>
<textarea>
Enter fullscreen mode Exit fullscreen mode

Avoid replacing them with:

<div onClick={...}>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

A keyboard user may not be able to focus or activate it.

Better:

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

8. Button types

Inside a form:

<button>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
  },
);
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Use an appropriate live region:

<p
  role="status"
  aria-live="polite"
>
  {statusMessage}
</p>
Enter fullscreen mode Exit fullscreen mode

For an urgent failure:

<div role="alert">
  Your session expired.
</div>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Use alert for important errors requiring attention:

Payment failed.
Session expired.
Unable to save changes.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

This provides visible text and prevents duplicate interaction.

You may also include:

<p
  role="status"
  aria-live="polite"
>
  {isSubmitting
    ? "Creating your account."
    : ""}
</p>
Enter fullscreen mode Exit fullscreen mode

Disabled versus aria-disabled

Native:

<button disabled>
  Submit
</button>
Enter fullscreen mode Exit fullscreen mode

prevents interaction and usually removes the control from the normal focus sequence.

<button aria-disabled="true">
  Submit
</button>
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

12. Do not disable submit solely because the form is invalid

Some forms use:

<button
  disabled={!isValid}
>
  Submit
</button>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Better:

Enter an email address in the format name@example.com.
Enter fullscreen mode Exit fullscreen mode

Bad:

Password error.
Enter fullscreen mode Exit fullscreen mode

Better:

Password must contain at least eight characters.
Enter fullscreen mode Exit fullscreen mode

An error should answer:

What is wrong?
How can I correct it?
Enter fullscreen mode Exit fullscreen mode

15. Do not rely only on color

Bad:

Red border = invalid
Green border = valid
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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%);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Better:

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

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Controlled input render flow

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

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

Each keystroke updates state.

User types "k"
       ↓
onChange runs
       ↓
setEmail("k")
       ↓
Form component renders
       ↓
Input receives value "k"
Enter fullscreen mode Exit fullscreen mode

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,
        );
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
          );
        }}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
        );
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

“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
Enter fullscreen mode Exit fullscreen mode

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")}
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

It consumes errors.

If it reads:

const {
  formState: {
    errors,
    isDirty,
    isValid,
    isSubmitting,
  },
} = useForm();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  • isDirty changes
  • isValid changes
  • isSubmitting changes
  • touchedFields changes
  • watch() observes a changed value
  • Controller updates 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
Enter fullscreen mode Exit fullscreen mode

Registered uncontrolled input

Possible flow:

Initial render: 1
First dirty-state change: render
Validation error changes: render when applicable
Submission-state changes: later renders
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

appears multiple times in development.

Measure production builds and user-visible interaction.


Performance problem 1: Watching the entire form

const values = watch();
Enter fullscreen mode Exit fullscreen mode

Now the component depends on every registered value.

Any field changes
      ↓
watch() value changes
      ↓
Component rerenders
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Isolate watched values

function StateField({
  control,
}: {
  control:
    Control<AddressFormValues>;
}) {
  const country =
    useWatch({
      control,
      name: "country",
    });

  if (country !== "india") {
    return null;
  }

  return (
    <input
      {...register("state")}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

In a real implementation, pass or retrieve the required registration methods appropriately.

The key idea is:

Only the conditional section
subscribes to country
Enter fullscreen mode Exit fullscreen mode

instead of making the complete parent form depend on it.


Performance problem 2: Validating on every keystroke

useForm({
  mode: "onChange",
  resolver:
    zodResolver(
      largeSchema,
    ),
});
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

Advantages:

  • Lowest validation frequency
  • Less interruption while typing
  • Good for simple forms

Disadvantage:

  • Feedback arrives after submission

onBlur

useForm({
  mode: "onBlur",
});
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

Advantages:

  • Immediate feedback
  • isValid can 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
Enter fullscreen mode Exit fullscreen mode

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."
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

With mode: "onChange", this might send a request for:

k
ka
kar
kart
karth
karthi
karthik
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every field may cause multiple expensive sections to update.

Solutions include:

  • Smaller component boundaries
  • Narrow subscriptions
  • Debounced preview updates
  • useDeferredValue where suitable
  • Memoized expensive components
  • Rendering previews only when required

Performance problem 5: Passing unstable props

Consider:

<ProfileInput
  options={{
    required: true,
  }}
/>
Enter fullscreen mode Exit fullscreen mode

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}
    />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

This field is controlled.

Using Controller does not make the component uncontrolled.

React Hook Form
      ↓
Controller
      ↓
Controlled custom component
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

Now every input may depend on a large changing object.

Prefer narrow props:

<Input
  error={
    errors.email?.message
  }
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  });
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Use during development:

function EmailSection() {
  return (
    <div>
      <RenderCounter
        name="EmailSection"
      />

      {/* Email input */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a debugging aid, not a production benchmark.


Use the React Profiler

For meaningful investigation:

  1. Record the interaction.
  2. Type in the slow field.
  3. Identify which components rendered.
  4. Find expensive renders.
  5. Inspect why their props or state changed.
  6. Optimize the actual bottleneck.
  7. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every field change calls:

setValues()
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

This form subscribes to:

errors
isDirty
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

when you only need to read a value during a button click.

Use:

watch()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The backend validates:

Structure
Business rules
Database state
Authorization
Tokens and sessions
Enter fullscreen mode Exit fullscreen mode

It then sends a consistent error response:

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

The frontend maps that response:

Axios catch
     ↓
error.response.data
     ↓
field = "email"
     ↓
setError("email")
     ↓
errors.email
     ↓
Email error paragraph
Enter fullscreen mode Exit fullscreen mode

Accessibility then connects the visible interface to semantic relationships:

label
  ↓
input
  ↓
aria-invalid
  ↓
aria-describedby
  ↓
error message
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The central lessons are:

  1. Frontend validation is for experience; backend validation is for trust.
  2. Error responses should use consistent codes, fields, and messages.
  3. setError() uses registered field paths to create errors such as errors.email.
  4. An error appears only where the UI explicitly renders that error path.
  5. Accessibility requires labels, instructions, keyboard behavior, focus management, and understandable errors.
  6. Rerenders are normal; optimize measured user-visible bottlenecks.
  7. 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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)