React Form Validation: The Complete Guide

Everything you need to know about validating forms in React applications.

Why Form Validation Matters

Good form validation:

  • Provides instant feedback to users
  • Prevents invalid data from reaching your backend
  • Improves user experience
  • Reduces server load
  • Protects against malicious input

Types of Validation

1. Client-Side Validation

Runs in the browser before submission. Provides instant feedback.

Pros:

  • Instant user feedback
  • Better UX (no page refresh)
  • Reduces server requests

Cons:

  • Can be bypassed
  • Not a security measure

2. Server-Side Validation

Runs on the backend after submission. Security layer.

Pros:

  • Cannot be bypassed
  • Secure validation
  • Access to database/business logic

Cons:

  • Slower feedback
  • Requires server request

The Right Approach: Both

Use client-side for UX, server-side for security. FormFlow does this automatically.

Client-Side Validation with FormFlow

Basic Required Fields

import { useFormFlow } from '@formflow.sh/react';
import { Input } from '@/components/ui/input';

function MyForm() {
  const { register, handleSubmit, formState } = useFormFlow({
    apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
  });

  return (
    <form onSubmit={handleSubmit}>
      <Input
        {...register('email', { required: 'Email is required' })}
        type="email"
      />
      {formState.errors.email && (
        <p className="text-red-500">{formState.errors.email.message}</p>
      )}

      <Input
        {...register('name', { required: 'Name is required' })}
      />
      {formState.errors.name && (
        <p className="text-red-500">{formState.errors.name.message}</p>
      )}

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

Email Validation

<Input
  {...register('email', {
    required: 'Email is required',
    pattern: {
      value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
      message: 'Invalid email address',
    },
  })}
  type="email"
/>

Number Validation (Min/Max)

<Input
  {...register('age', {
    required: 'Age is required',
    min: {
      value: 18,
      message: 'Must be at least 18 years old',
    },
    max: {
      value: 120,
      message: 'Invalid age',
    },
  })}
  type="number"
/>

String Length Validation

<Input
  {...register('username', {
    required: 'Username is required',
    minLength: {
      value: 3,
      message: 'Username must be at least 3 characters',
    },
    maxLength: {
      value: 20,
      message: 'Username must be less than 20 characters',
    },
  })}
/>

Custom Validation Functions

<Input
  {...register('password', {
    required: 'Password is required',
    validate: {
      hasUpperCase: (value) =>
        /[A-Z]/.test(value) || 'Must contain uppercase letter',
      hasLowerCase: (value) =>
        /[a-z]/.test(value) || 'Must contain lowercase letter',
      hasNumber: (value) =>
        /[0-9]/.test(value) || 'Must contain number',
      minLength: (value) =>
        value.length >= 8 || 'Must be at least 8 characters',
    },
  })}
  type="password"
/>

Dependent Field Validation

const { register, watch } = useFormFlow({ ... });
const password = watch('password');

<Input
  {...register('confirmPassword', {
    required: 'Please confirm password',
    validate: (value) =>
      value === password || 'Passwords do not match',
  })}
  type="password"
/>

Real-Time Validation

Show validation errors as user types:

const { register, formState, trigger } = useFormFlow({ ... });

<Input
  {...register('email', {
    required: 'Email is required',
    pattern: {
      value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
      message: 'Invalid email',
    },
  })}
  onChange={(e) => {
    // Trigger validation on change
    trigger('email');
  }}
/>
{formState.errors.email && (
  <p className="text-red-500 text-sm mt-1">
    {formState.errors.email.message}
  </p>
)}

Server-Side Validation

FormFlow automatically validates on the server. But you can add custom validation:

const { register, handleSubmit } = useFormFlow({
  apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
  onError: (error) => {
    // Handle server validation errors
    if (error.message.includes('email already exists')) {
      // Show custom error to user
      alert('This email is already registered');
    }
  },
});

Validation Patterns

Email

/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i

Phone (US)

/^(\+1)?[\s.-]?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/

URL

/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b/

Credit Card (Simple)

/^\d{13,19}$/

Advanced: Async Validation

Check if username/email is available:

<Input
  {...register('username', {
    required: 'Username is required',
    validate: async (value) => {
      const response = await fetch(`/api/check-username/${value}`);
      const data = await response.json();
      return data.available || 'Username is taken';
    },
  })}
/>

Error Display Patterns

Inline Errors

<div>
  <Input {...register('email', { required: 'Required' })} />
  {formState.errors.email && (
    <p className="text-red-500 text-sm mt-1">
      {formState.errors.email.message}
    </p>
  )}
</div>

Error Summary at Top

{Object.keys(formState.errors).length > 0 && (
  <div className="bg-red-50 border border-red-200 rounded p-4 mb-4">
    <h3 className="font-semibold text-red-800 mb-2">
      Please fix the following errors:
    </h3>
    <ul className="list-disc list-inside text-red-700 text-sm">
      {Object.entries(formState.errors).map(([field, error]) => (
        <li key={field}>{error.message}</li>
      ))}
    </ul>
  </div>
)}

Toast Notifications

import { toast } from 'sonner';

const { register, handleSubmit } = useFormFlow({
  apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
  onSuccess: () => {
    toast.success('Form submitted successfully!');
  },
  onError: (error) => {
    toast.error('Failed to submit form: ' + error.message);
  },
});

Best Practices

1. Always Validate on Both Sides

Client-side for UX, server-side for security. Never trust client-only validation.

2. Provide Clear Error Messages

Bad: "Invalid input"
Good: "Email must be in format: user@example.com"

3. Validate on the Right Events

  • onBlur: Good for most fields (validate when user leaves field)
  • onChange: Good for password strength, username availability
  • onSubmit: Always validate before submission

4. Show Validation State Visually

<Input
  {...register('email')}
  className={
    formState.errors.email
      ? 'border-red-500'
      : formState.dirtyFields.email
      ? 'border-green-500'
      : ''
  }
/>

5. Disable Submit Button During Validation

<Button
  type="submit"
  disabled={formState.isSubmitting || !formState.isValid}
>
  {formState.isSubmitting ? 'Submitting...' : 'Submit'}
</Button>

Complete Example: Registration Form

import { useFormFlow } from '@formflow.sh/react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';

export function RegistrationForm() {
  const { register, handleSubmit, formState, watch } = useFormFlow({
    apiKey: process.env.NEXT_PUBLIC_FORMFLOW_API_KEY,
    onSuccess: () => {
      alert('Registration successful!');
    },
  });

  const password = watch('password');

  return (
    <form onSubmit={handleSubmit} className="space-y-4 max-w-md">
      {/* Email */}
      <div>
        <Label htmlFor="email">Email *</Label>
        <Input
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: 'Invalid email address',
            },
          })}
          id="email"
          type="email"
        />
        {formState.errors.email && (
          <p className="text-red-500 text-sm mt-1">
            {formState.errors.email.message}
          </p>
        )}
      </div>

      {/* Username */}
      <div>
        <Label htmlFor="username">Username *</Label>
        <Input
          {...register('username', {
            required: 'Username is required',
            minLength: {
              value: 3,
              message: 'Must be at least 3 characters',
            },
            pattern: {
              value: /^[a-zA-Z0-9_]+$/,
              message: 'Only letters, numbers, and underscores',
            },
          })}
          id="username"
        />
        {formState.errors.username && (
          <p className="text-red-500 text-sm mt-1">
            {formState.errors.username.message}
          </p>
        )}
      </div>

      {/* Password */}
      <div>
        <Label htmlFor="password">Password *</Label>
        <Input
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Must be at least 8 characters',
            },
            validate: {
              hasUpperCase: (v) =>
                /[A-Z]/.test(v) || 'Must contain uppercase',
              hasLowerCase: (v) =>
                /[a-z]/.test(v) || 'Must contain lowercase',
              hasNumber: (v) =>
                /[0-9]/.test(v) || 'Must contain number',
            },
          })}
          id="password"
          type="password"
        />
        {formState.errors.password && (
          <p className="text-red-500 text-sm mt-1">
            {formState.errors.password.message}
          </p>
        )}
      </div>

      {/* Confirm Password */}
      <div>
        <Label htmlFor="confirmPassword">Confirm Password *</Label>
        <Input
          {...register('confirmPassword', {
            required: 'Please confirm password',
            validate: (value) =>
              value === password || 'Passwords do not match',
          })}
          id="confirmPassword"
          type="password"
        />
        {formState.errors.confirmPassword && (
          <p className="text-red-500 text-sm mt-1">
            {formState.errors.confirmPassword.message}
          </p>
        )}
      </div>

      <Button
        type="submit"
        disabled={formState.isSubmitting}
        className="w-full"
      >
        {formState.isSubmitting ? 'Registering...' : 'Register'}
      </Button>
    </form>
  );
}

Conclusion

Good form validation is essential for great UX and security. FormFlow makes it easy with:

  • Built-in client-side validation (via react-hook-form)
  • Automatic server-side validation
  • Spam protection
  • Rate limiting

You get production-grade validation out of the box.

Related Resources

Ready to build better forms?

Get validation, backend, and more - all included.

React Form Validation: The Complete Guide | FormFlow Blog | FormFlow