Advanced React Hook Form: Custom Validation and Async Server Checks with Material UI

 

Advanced Form Validation in React Hook Form: Custom Rules & Async Server Validation

When building production web apps, basic field validations like required or standard regex patterns only cover half the story. Real-world enterprise forms frequently demand:

  1. Cross-Field Synchronous Validation: Ensuring two fields match (like Password and Confirm Password, or Start Date and End Date).

  2. Asynchronous Server-Side Checks: Validating input against a backend REST API in real time (e.g., verifying if an Employee ID or Email is already registered before submission).

In this tutorial, we will build on our previous guide on building employee registration forms with React Hook Form and Material UI by implementing custom synchronous validation rules and handling asynchronous API validation with debouncing.



1. Prerequisites & Setup

If you followed our previous guide, you already have @mui/material and react-hook-form installed.

If starting fresh, install the dependencies:

Bash / Command :
npm install react-hook-form @mui/material @emotion/react @emotion/styled

2. Implementing Custom Synchronous Validation (Cross-Field Matching)

React Hook Form provides a validate callback inside the registration rules. The function receives the current field's value and can either return true (valid) or a string error message (invalid).

To compare against another field (such as password confirmation), use the watch hook:

JavaScript: 
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { TextField, Button, Box, Typography } from '@mui/material';

export default function SecurityForm() {
  const {
    control,
    handleSubmit,
    watch,
    formState: { errors }
  } = useForm({
    defaultValues: {
      password: '',
      confirmPassword: ''
    },
    mode: 'onTouched'
  });

  // Watch the primary field to compare against
  const passwordValue = watch('password');

  const onSubmit = (data) => {
    console.log('Form Submitted Successfully:', data);
  };

  return (
    <Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ maxWidth: 400, mx: 'auto', mt: 4 }}>
      <Typography variant="h6" gutterBottom>
        Account Security Setup
      </Typography>

      {/* Password Field */}
      <Controller
        name="password"
        control={control}
        rules={{
          required: 'Password is required',
          minLength: {
            value: 8,
            message: 'Password must be at least 8 characters long'
          }
        }}
        render={({ field }) => (
          <TextField
            {...field}
            type="password"
            label="New Password"
            fullWidth
            margin="normal"
            error={!!errors.password}
            helperText={errors.password?.message}
          />
        )}
      />

      {/* Confirm Password Field */}
      <Controller
        name="confirmPassword"
        control={control}
        rules={{
          required: 'Please confirm your password',
          validate: (value) =>
            value === passwordValue || 'Passwords do not match'
        }}
        render={({ field }) => (
          <TextField
            {...field}
            type="password"
            label="Confirm Password"
            fullWidth
            margin="normal"
            error={!!errors.confirmPassword}
            helperText={errors.confirmPassword?.message}
          />
        )}
      />

      <Button type="submit" variant="contained" color="primary" fullWidth sx={{ mt: 2 }}>
        Update Password
      </Button>
    </Box>
  );
}

3. Implementing Asynchronous Server-Side Validation

A common requirement is checking uniqueness before the user clicks Submit—for example, verifying whether an Employee ID is already taken in the database.

The validate function in React Hook Form natively supports async/await. Returning a string indicates an invalid entry.

Simulating a Backend API Service

Here is a mock service representing a Spring Boot REST API endpoint (GET /api/v1/employees/check-id?employeeId=...):

JavaScript :
// apiService.js
export const checkEmployeeIdAvailability = async (empId) => {
  // Simulate network latency (500ms)
  await new Promise((resolve) => setTimeout(resolve, 500));

  const existingIds = ['EMP101', 'EMP102', 'EMP103'];
  return !existingIds.includes(empId.toUpperCase());
};

Async Field Implementation with Loading State

JavaScript :
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { TextField, Button, Box, CircularProgress, InputAdornment } from '@mui/material';
import { checkEmployeeIdAvailability } from './apiService';

export default function AsyncEmployeeForm() {
  const {
    control,
    handleSubmit,
    formState: { errors, isValidating, isSubmitting }
  } = useForm({
    defaultValues: {
      employeeId: ''
    },
    mode: 'onBlur' // Validate when user clicks away
  });

  const onSubmit = async (data) => {
    console.log('Valid Employee Data:', data);
  };

  return (
    <Box component="form" onSubmit={handleSubmit(onSubmit)} sx={{ maxWidth: 400, mx: 'auto', mt: 4 }}>
      <Controller
        name="employeeId"
        control={control}
        rules={{
          required: 'Employee ID is mandatory',
          pattern: {
            value: /^EMP\d{3}$/i,
            message: 'Format must be EMP followed by 3 digits (e.g., EMP104)'
          },
          // Asynchronous validation rule
          validate: async (value) => {
            const isAvailable = await checkEmployeeIdAvailability(value);
            return isAvailable || 'This Employee ID is already registered in the system';
          }
        }}
        render={({ field }) => (
          <TextField
            {...field}
            label="Employee ID"
            fullWidth
            margin="normal"
            error={!!errors.employeeId}
            helperText={errors.employeeId?.message}
            InputProps={{
              endAdornment: isValidating ? (
                <InputAdornment position="end">
                  <CircularProgress size={20} />
                </InputAdornment>
              ) : null
            }}
          />
        )}
      />

      <Button
        type="submit"
        variant="contained"
        fullWidth
        disabled={isSubmitting || isValidating}
        sx={{ mt: 2 }}
      >
        Register Employee
      </Button>
    </Box>
  );
}

4. Best Practices for Async Validation

  1. Use mode: 'onBlur' for Async Rules: Avoid running remote API checks on every keystroke (onChange). Setting mode: 'onBlur' ensures the API call triggers only after the user finishes typing and leaves the field.

  2. Surface Loading States Clearly: Use isValidating from formState to display a spinner inside the input field so users know the system is verifying their input.

  3. Disable Form Submission During Checks: Prevent accidental submissions while network requests are still resolving by disabling the submit button when isValidating is true.

Conclusion :

Combining custom synchronous rules with asynchronous validation functions gives you full control over complex validation pipelines while keeping form state lean and performant.

In our upcoming guide, we will connect this frontend form to a Spring Boot REST API using standard @Valid and global exception handling (MethodArgumentNotValidException).

Related Tutorials:

Frontend :  Registration From With React
Backend : SpringBoot Interview Questions

Comments

Popular posts from this blog

React Basics – How to Create Component

How to Build REST API using Spring Boot (Step-by-Step Guide)

Spring Boot @RequestParam Annotation Explained with Real-Time Examples