Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

React Forms and Validation: Production Patterns

March 11, 2025 · 8 min read

React, Forms, Validation, React Hook Form, Interview Prep

Introduction

Forms are essential for most applications - user registration, data entry, search, filters. While React makes basic forms straightforward, production forms need validation, error handling, and good UX. This article covers patterns from basic controlled forms to React Hook Form + Zod solutions.


Controlled vs Uncontrolled Forms

Controlled: React Owns the State

function ControlledForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    login({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
    </form>
  );
}

Uncontrolled: DOM Owns the State

function UncontrolledForm() {
  const emailRef = useRef();
  const passwordRef = useRef();

  const handleSubmit = (e) => {
    e.preventDefault();
    login({
      email: emailRef.current.value,
      password: passwordRef.current.value,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" ref={emailRef} defaultValue="" />
      <input type="password" ref={passwordRef} defaultValue="" />
      <button type="submit">Login</button>
    </form>
  );
}

When to Use Which

Controlled: When you need real-time validation, conditional fields, or derived values. Uncontrolled: For simple forms where you only need values on submit. React Hook Form uses uncontrolled by default for performance, but gives you controlled-like DX.


Manual Validation

Before introducing libraries, understand the basics:

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  const validate = () => {
    const newErrors = {};

    if (!email) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = 'Invalid email format';
    }

    if (!password) {
      newErrors.password = 'Password is required';
    } else if (password.length < 8) {
      newErrors.password = 'Password must be at least 8 characters';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!validate()) return;

    setIsSubmitting(true);
    try {
      await login({ email, password });
    } catch (err) {
      setErrors({ form: 'Invalid credentials' });
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {errors.form && <div className="error">{errors.form}</div>}

      <div>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Email"
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <input
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          placeholder="Password"
        />
        {errors.password && <span className="error">{errors.password}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Log In'}
      </button>
    </form>
  );
}

Problems with Manual Forms

  1. Boilerplate: State for every field
  2. Validation logic: Manual, error-prone
  3. Performance: Every keystroke re-renders
  4. Complex forms: Nested objects, arrays become unwieldy

React Hook Form

React Hook Form uses uncontrolled components by default, minimizing re-renders while providing a powerful API.

import { useForm } from 'react-hook-form';

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm();

  const onSubmit = async (data) => {
    await login(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /\S+@\S+\.\S+/,
              message: 'Invalid email format'
            }
          })}
          type="email"
          placeholder="Email"
        />
        {errors.email && <span className="error">{errors.email.message}</span>}
      </div>

      <div>
        <input
          {...register('password', {
            required: 'Password is required',
            minLength: {
              value: 8,
              message: 'Password must be at least 8 characters'
            }
          })}
          type="password"
          placeholder="Password"
        />
        {errors.password && <span className="error">{errors.password.message}</span>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Log In'}
      </button>
    </form>
  );
}

Watch and Conditional Fields

function RegistrationForm() {
  const { register, watch } = useForm();

  const accountType = watch('accountType');

  return (
    <form>
      <select {...register('accountType')}>
        <option value="personal">Personal</option>
        <option value="business">Business</option>
      </select>

      {/* Conditional field */}
      {accountType === 'business' && (
        <input
          {...register('companyName', { required: 'Company name is required' })}
          placeholder="Company Name"
        />
      )}
    </form>
  );
}

Zod Schema Validation

Zod provides TypeScript-first schema validation with excellent type inference.

import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';

const loginSchema = z.object({
  email: z
    .string()
    .min(1, 'Email is required')
    .email('Invalid email format'),
  password: z
    .string()
    .min(1, 'Password is required')
    .min(8, 'Password must be at least 8 characters')
});

// TypeScript type is inferred!
type LoginFormData = z.infer<typeof loginSchema>;

function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema)
  });

  const onSubmit = (data: LoginFormData) => {
    // data is fully typed and validated
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Same as before */}
    </form>
  );
}

Advanced Zod Patterns

// Password confirmation
const passwordSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string()
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword']
});

// Conditional validation
const userSchema = z.object({
  role: z.enum(['user', 'admin']),
  adminCode: z.string().optional()
}).refine(
  (data) => data.role !== 'admin' || data.adminCode,
  {
    message: 'Admin code required for admin role',
    path: ['adminCode']
  }
);

// Transform values
const formSchema = z.object({
  price: z.string().transform((val) => parseFloat(val)),
  date: z.string().transform((val) => new Date(val))
});

// Coerce types (for form inputs)
const querySchema = z.object({
  page: z.coerce.number().default(1),
  limit: z.coerce.number().default(10)
});

Dynamic Forms with Field Arrays

import { useForm, useFieldArray } from 'react-hook-form';

function RecipeForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: {
      name: '',
      ingredients: [{ name: '', amount: '' }]
    }
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'ingredients'
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} placeholder="Recipe name" />

      <h3>Ingredients</h3>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`ingredients.${index}.name`)}
            placeholder="Ingredient name"
          />
          <input
            {...register(`ingredients.${index}.amount`)}
            placeholder="Amount"
          />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: '', amount: '' })}>
        Add Ingredient
      </button>

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

Use field.id for Keys

Always use field.id as the key, not the array index. React Hook Form generates stable IDs that prevent bugs when reordering or removing items.

Field Array Lessons

Building dynamic forms in Glucoplate, I learned the hard way about field.id vs index. Index-based keys caused input focus to jump unexpectedly when removing items from the middle. Always use the stable ID that React Hook Form provides - it exists for a reason.


Reusable Form Components

import { useFormContext } from 'react-hook-form';

function Input({ name, label, ...props }) {
  const {
    register,
    formState: { errors }
  } = useFormContext();

  const error = errors[name];

  return (
    <div className="form-field">
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        {...register(name)}
        {...props}
        aria-invalid={!!error}
      />
      {error && (
        <span className="error-message" role="alert">
          {error.message}
        </span>
      )}
    </div>
  );
}

// Usage with FormProvider
import { FormProvider, useForm } from 'react-hook-form';

function ContactForm() {
  const methods = useForm();

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <Input name="name" label="Name" placeholder="Your name" />
        <Input name="email" label="Email" type="email" />
        <Input name="message" label="Message" />
        <button type="submit">Send</button>
      </form>
    </FormProvider>
  );
}

Handling Server Errors

function RegistrationForm() {
  const { register, handleSubmit, setError, formState: { errors } } = useForm();

  const onSubmit = async (data) => {
    try {
      await registerUser(data);
    } catch (err) {
      // Handle field-specific errors from server
      if (err.response?.data?.field) {
        setError(err.response.data.field, {
          message: err.response.data.message
        });
      } else {
        // General form error
        setError('root', {
          message: err.message || 'Something went wrong'
        });
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {errors.root && (
        <div className="form-error">{errors.root.message}</div>
      )}
      {/* fields */}
    </form>
  );
}

Interview Questions

Q1: Controlled vs uncontrolled components - when to use each?

Answer:

ControlledUncontrolled
Real-time validationLess re-renders
Instant field updatesSimpler for basic forms
Full control over inputBetter performance
More boilerplateLess control

Use controlled for complex forms with dependencies between fields. Use uncontrolled (or React Hook Form) for performance-critical forms.

Q2: How does React Hook Form improve performance?

Answer:

  1. Uncontrolled inputs: No state updates on keystroke
  2. Isolated re-renders: Only components subscribed to specific fields re-render
  3. Proxy-based subscriptions: Fine-grained reactivity

Typing in one field doesn't re-render the entire form.

Q3: How do you handle form validation with Zod?

Answer:

  1. Define schema with Zod
  2. Use zodResolver with React Hook Form
  3. Types are inferred automatically

Benefits: Single source of truth for types and validation, runtime validation matches TypeScript types.

Q4: How do you handle async validation?

Answer:

// With Zod refine
const schema = z.object({
  email: z.string().email()
}).refine(async (data) => {
  const exists = await checkEmailExists(data.email);
  return !exists;
}, {
  message: 'Email already taken',
  path: ['email']
});

// Or with React Hook Form validate
register('username', {
  validate: async (value) => {
    const available = await checkUsername(value);
    return available || 'Username taken';
  }
});

Common Mistakes

1. Forgetting valueAsNumber

// ❌ WRONG - value is string
<input type="number" {...register('age')} />

// ✅ CORRECT - value is number
<input type="number" {...register('age', { valueAsNumber: true })} />

2. Validating on Every Keystroke

// ❌ Can be annoying UX
useForm({ mode: 'onChange' });

// ✅ Better - validate on blur, then onChange after error
useForm({ mode: 'onBlur' });
// or
useForm({ mode: 'onTouched' });

3. Missing Key in Field Arrays

// ❌ WRONG - using index as key
fields.map((field, index) => (
  <div key={index}>...</div>  // Causes issues when reordering
))

// ✅ CORRECT - using field.id
fields.map((field, index) => (
  <div key={field.id}>...</div>  // Stable identity
))

Summary

  • React Hook Form provides performance and great DX
  • Zod enables type-safe validation with inference
  • useFieldArray handles dynamic form fields
  • FormProvider enables nested form components
  • Validation modes affect UX (onSubmit, onBlur, onChange)
  • Handle server errors with setError

Next up: State Management with Context, Redux Toolkit, and Zustand.


Part 6 of the React Developer Reference series.

← Previous

React State Management: Context, Redux, and Zustand

Next →

React API Integration: Data Fetching Mastery