Building a Custom Form Validation Hook with Zod in React TypeScript

User avatar placeholder
Written by Tamzid Ahmed

June 1, 2026

In modern React applications, form validation is a critical yet often overlooked aspect of user experience. When combined with TypeScript, ensuring type safety in form data becomes essential to prevent runtime errors and improve code reliability. This guide walks you through building a custom form validation hook using Zod — a powerful schema validation library — to create robust, maintainable forms without third-party dependencies.

Why Type-Safe Form Validation Matters

Manual form validation in JavaScript often leads to runtime errors and inconsistent data. TypeScript helps catch issues at compile time, but form data is dynamic and requires runtime checks. Zod bridges this gap by allowing you to define schemas that validate data against strict types, ensuring your application handles data correctly from the start.

Setting Up Zod for React TypeScript Projects

Start by installing Zod via npm or yarn:

  1. Run npm install zod or yarn add zod
  2. Create a schema for your form fields using Zod’s object syntax
  3. Use TypeScript to infer types from the schema for consistent type checking

For example, a user registration form schema might look like this:

import { z } from 'zod';

const registrationSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  terms: z.boolean().refine(value => value === true, 'You must accept terms')
});

This schema ensures email format, password length, and terms acceptance — all with detailed error messages.

Building a Custom Form Validation Hook with Zod in React TypeScript

Here’s how to create a reusable hook that handles validation:

  1. Use useState to manage form values and errors
  2. Implement a validation function that uses schema.safeParse()
  3. Return the current state and validation logic to your form components

Example implementation:

import { useState } from 'react';
import { z } from 'zod';

export const useZodForm = (schema: z.ZodType) => {
  const [values, setValues] = useState({});
  const [errors, setErrors] = useState({});

  const validate = (data: unknown) => {
    const result = schema.safeParse(data);
    if (result.success) {
      setErrors({});
      return true;
    }
    const fieldErrors = result.error.errors.reduce((acc, err) => {
      acc[err.path[0]] = err.message;
      return acc;
    }, {});
    setErrors(fieldErrors);
    return false;
  };

  return { values, errors, validate, setValues };
};

This hook centralizes validation logic, making it easy to reuse across forms while maintaining type safety.

Handling Validation Errors Gracefully

Displaying user-friendly errors is crucial for a good experience. Use the errors object returned by the hook to render messages next to each field:

<input
  type="email"
  value={values.email}
  onChange={(e) => setValues({ ...values, email: e.target.value })}
/>
{errors.email && <span className="error">{errors.email}</span>}

For complex errors, consider mapping Zod error messages to more conversational language or grouping errors by field severity.

Practical Example: Registration Form

Here’s a complete registration form using the hook:

function RegistrationForm() {
  const { values, errors, validate, setValues } = useZodForm(registrationSchema);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (validate(values)) {
      // Proceed with submission
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        value={values.email || ''}
        onChange={(e) => setValues({ ...values, email: e.target.value })}
      />
      {errors.email && <span className="error">{errors.email}</span>}

      <input
        name="password"
        type="password"
        value={values.password || ''}
        onChange={(e) => setValues({ ...values, password: e.target.value })}
      />
      {errors.password && <span className="error">{errors.password}</span>}

      <label>
        <input
          type="checkbox"
          checked={values.terms}
          onChange={(e) => setValues({ ...values, terms: e.target.checked })}
        />
        I accept the terms
      </label>
      {errors.terms && <span className="error">{errors.terms}</span>}

      <button type="submit">Register</button>
    </form>
  );
}

This example demonstrates how the hook integrates with form fields, handles validation on submit, and displays errors clearly.

Benefits and Tradeoffs

Using a custom Zod hook offers significant advantages:

  • Type safety across your entire form lifecycle
  • No external dependencies beyond Zod
  • Full control over validation logic and error handling

However, consider these tradeoffs:

  • Requires manual implementation of features like debouncing or async validation
  • Lacks built-in form state management found in libraries like React Hook Form
  • More boilerplate code for complex forms

For simple forms, this approach provides lightweight, maintainable validation. For complex forms with advanced features, libraries like React Hook Form may be more efficient.

Conclusion

Building a custom form validation hook with Zod in React TypeScript ensures type-safe, maintainable forms while avoiding unnecessary dependencies. By centralizing validation logic and leveraging Zod’s powerful schema system, you reduce runtime errors and improve developer experience. Start by defining your Zod schema first — this sets the foundation for consistent data handling across your application. For further optimization, consider combining this hook with React Query for seamless form submission handling.

Leave a Comment