Skip to content

Fix: React Hook Form Not Working — register Not Applying, Validation Not Triggering, or Controller Issues

FixDevs ·

Quick Answer

How to fix React Hook Form issues — register spread syntax, Controller for UI libraries, validation modes, watch vs getValues, nested fields, and form submission errors.

The Problem

register() doesn’t bind to the input:

const { register } = useForm();

// Input value is never captured
<input name="email" />  // Missing register — form data will be empty

// Or incorrect spread
const emailRef = register('email');
<input ref={emailRef} />  // Missing name, onChange, onBlur

Or validation never fires despite rules being set:

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

<input {...register('email', { required: true })} />
{errors.email && <p>Email is required</p>}  // Never shows

// Form submits without validation
<form onSubmit={handleSubmit(onSubmit)}>

Or a UI library component (MUI, Ant Design, shadcn/ui) doesn’t integrate with register:

// TextField from MUI — register doesn't work directly
<TextField {...register('email')} />  // Value is never captured

Why This Happens

React Hook Form uses uncontrolled inputs by default — it registers a ref on each input to read values directly from the DOM. Several things break this:

  • Missing register spreadregister('fieldName') returns { name, ref, onChange, onBlur }. You must spread all of these onto the input. Passing only ref or only name loses the event bindings.
  • Wrong validation trigger mode — by default, RHF only validates on submit (mode: 'onSubmit'). If you’re expecting real-time validation, set mode: 'onChange' or mode: 'onBlur'.
  • UI library components — controlled components from UI libraries (MUI TextField, Ant Design Select, etc.) often manage their own refs and don’t work with RHF’s uncontrolled approach. Use Controller for these.
  • Nested objects vs flat namesregister('user.email') creates a nested value { user: { email: '...' } }. Accessing errors.user.email requires dot notation or get(errors, 'user.email').

Fix 1: Spread All register Return Values

register() returns multiple properties — spread all of them:

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

type FormValues = {
  email: string;
  password: string;
  age: number;
};

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>();

  const onSubmit = (data: FormValues) => {
    console.log(data);  // { email: '...', password: '...', age: 25 }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* WRONG — only name, missing ref/onChange/onBlur */}
      <input name="email" />

      {/* CORRECT — spread all register props */}
      <input
        type="email"
        {...register('email', {
          required: 'Email is required',
          pattern: {
            value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
            message: 'Invalid email address',
          },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}

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

      {/* Number input — use valueAsNumber */}
      <input
        type="number"
        {...register('age', {
          valueAsNumber: true,
          min: { value: 18, message: 'Must be 18 or older' },
        })}
      />
      {errors.age && <p>{errors.age.message}</p>}

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

Fix 2: Set Validation Mode

Control when validation runs:

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

const form = useForm<FormValues>({
  mode: 'onSubmit',       // Default — only on submit
  mode: 'onBlur',         // Validate when field loses focus
  mode: 'onChange',       // Validate on every keystroke (can be slow)
  mode: 'onTouched',      // First on blur, then on change after first blur
  mode: 'all',            // onChange + onBlur

  reValidateMode: 'onChange',  // After first submit error, re-validate on change
});

Per-field validation trigger:

// Validate this field on blur regardless of global mode
<input
  {...register('email', {
    required: true,
    // validation rules apply with whatever mode is set globally
  })}
/>

// Manually trigger validation
const { trigger } = useForm();
await trigger('email');        // Validate one field
await trigger(['email', 'password']);  // Validate multiple
await trigger();               // Validate all fields

Fix 3: Use Controller for UI Library Components

Controller wraps controlled components and bridges them with RHF:

import { useForm, Controller } from 'react-hook-form';
import { TextField, Select, MenuItem, Checkbox } from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers';

type FormValues = {
  email: string;
  country: string;
  birthdate: Date | null;
  newsletter: boolean;
};

export function ProfileForm() {
  const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
    defaultValues: {
      email: '',
      country: '',
      birthdate: null,
      newsletter: false,
    },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {/* MUI TextField */}
      <Controller
        name="email"
        control={control}
        rules={{ required: 'Email is required' }}
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            label="Email"
            error={!!fieldState.error}
            helperText={fieldState.error?.message}
          />
        )}
      />

      {/* MUI Select */}
      <Controller
        name="country"
        control={control}
        rules={{ required: 'Select a country' }}
        render={({ field }) => (
          <Select {...field} displayEmpty>
            <MenuItem value="">Choose...</MenuItem>
            <MenuItem value="us">United States</MenuItem>
            <MenuItem value="uk">United Kingdom</MenuItem>
          </Select>
        )}
      />

      {/* MUI DatePicker */}
      <Controller
        name="birthdate"
        control={control}
        render={({ field }) => (
          <DatePicker
            label="Birth date"
            value={field.value}
            onChange={field.onChange}
            inputRef={field.ref}
          />
        )}
      />

      {/* Checkbox */}
      <Controller
        name="newsletter"
        control={control}
        render={({ field }) => (
          <Checkbox
            checked={field.value}
            onChange={(e) => field.onChange(e.target.checked)}
          />
        )}
      />

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

shadcn/ui integration:

import { Controller } from 'react-hook-form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';

// shadcn/ui Input — works directly with register
<Input {...register('email')} />

// shadcn/ui Select — needs Controller
<Controller
  name="role"
  control={control}
  render={({ field }) => (
    <Select onValueChange={field.onChange} defaultValue={field.value}>
      <SelectTrigger>
        <SelectValue placeholder="Select role" />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="admin">Admin</SelectItem>
        <SelectItem value="user">User</SelectItem>
      </SelectContent>
    </Select>
  )}
/>

Fix 4: Handle Nested Fields and Arrays

RHF supports complex data shapes with dot notation and useFieldArray:

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

type FormValues = {
  user: {
    name: string;
    address: {
      street: string;
      city: string;
    };
  };
  tags: { value: string }[];
};

export function ComplexForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>();

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

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {/* Nested field with dot notation */}
      <input {...register('user.name', { required: true })} />
      {errors.user?.name && <p>Name required</p>}

      <input {...register('user.address.street')} />
      <input {...register('user.address.city')} />

      {/* Dynamic array fields */}
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`tags.${index}.value`)} />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ value: '' })}>
        Add Tag
      </button>

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

Fix 5: Watch Values and Set Defaults

Reading form values reactively and setting initial data:

const {
  register,
  watch,
  getValues,
  setValue,
  reset,
} = useForm<FormValues>({
  defaultValues: {
    email: '',
    role: 'user',
    notifications: true,
  },
});

// watch() — reactive, causes re-render on every change
const email = watch('email');
const allValues = watch();  // Watch all fields

// getValues() — non-reactive, doesn't cause re-render
// Use inside event handlers and callbacks
const handlePreview = () => {
  const currentValues = getValues();
  showPreview(currentValues);
};

// setValue() — programmatically set a value
setValue('email', '[email protected]');
setValue('email', '[email protected]', {
  shouldValidate: true,   // Trigger validation
  shouldDirty: true,      // Mark field as dirty
});

// reset() — reset to defaultValues or new values
reset();  // Reset to defaultValues
reset({ email: '[email protected]', role: 'admin' });  // Reset to new values

// Load async data into form
useEffect(() => {
  if (userData) {
    reset(userData);  // Populate form with fetched data
  }
}, [userData, reset]);

Fix 6: Integrate with Zod for Schema Validation

Use Zod (or Yup) for declarative, reusable validation:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  confirmPassword: z.string(),
  age: z.number().min(18, 'Must be 18 or older'),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords must match',
    path: ['confirmPassword'],  // Error appears on confirmPassword field
  }
);

type FormValues = z.infer<typeof schema>;

export function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: {
      email: '',
      password: '',
      confirmPassword: '',
      age: 0,
    },
  });

  const onSubmit = async (data: FormValues) => {
    await registerUser(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password')} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <input
        type="number"
        {...register('age', { valueAsNumber: true })}
      />
      {errors.age && <p>{errors.age.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Registering...' : 'Register'}
      </button>
    </form>
  );
}

Still Not Working?

formState.errors is empty but form doesn’t submit — check if handleSubmit is on the <form> element’s onSubmit, not on the button’s onClick. The handleSubmit wrapper runs validation before calling your submit handler.

register in a custom input component — if you wrap an input in a custom component, forward the ref using React.forwardRef, or use Controller instead:

// Custom input that works with register
const CustomInput = React.forwardRef<HTMLInputElement, InputProps>(
  ({ onChange, onBlur, name, label }, ref) => (
    <div>
      <label>{label}</label>
      <input ref={ref} name={name} onChange={onChange} onBlur={onBlur} />
    </div>
  )
);

TypeScript errors on nested field paths — use Path<FormValues> for type-safe field names, or use the as const assertion. Template literal paths like `tags.${index}.value` may need as Path<FormValues> in some TypeScript configurations.

For related React issues, see Fix: React Too Many Re-renders and Fix: React useEffect Infinite Loop.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles