How to focus when an error occurs in react-hook-form Controller?
Asked Answered
D

8

19

I am rendering a custom component using the Controller of react-hook-form.

If an error occurs when using register, focus is normal, but the Controller does not.

I was able to find a way to handle an error when the Custom Component processes only a single input, but I couldn't find a way to handle it when it has multiple inputs.

Form.js

import { Controller, useForm } from "react-hook-form";
import CustomInput from "./CustomInput";
import * as yup from "yup";

const schema = yup.object({
  name: yup.object().shape({
    first: yup.string().required(),
    last: yup.string().required(),
  }),
});
function Form() {
  const {
    handleSubmit,
    control,
    formState: { errors },
  } = useForm({
    defaultValues: { name: { first: "", last: "" } },
    resolver: yupResolver(schema),
  });

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="name"
        control={control}
        rules={{ required: true }}
        render={({ field }) => (
          <CustomInput
            value={field.value}
            onChange={(value) => field.onChange(value)}
            errors={errors}
          />
        )}
      />
      <button type="submit">Send</button>
    </form>
  );
}

export default Form;

CustomInput.js

function CustomInput({ value, onChange, errors }) {
  const changeFirst = (e) => {
    onChange({ first: e.target.value, last: value?.last });
  };

  const changeLast = (e) => {
    onChange({ first: value?.first, last: e.target.value });
  };

  return (
    <div>
      <input type="text" value={value.first} onChange={changeFirst} />
      <input type="text" value={value.last} onChange={changeLast} />
      {errors?.name && (
        <p className="errmsg">
          {errors?.name?.first?.message || errors?.name?.last?.message}
        </p>
      )}
    </div>
  );
}

export default CustomInput;

​How can I get error focus when there are multiple inputs in a custom component?

Dislocate answered 26/10, 2021 at 8:12 Comment(0)
N
17

You can use setFocus from RHF. First, detect when the errors object changes and then find the first field which has the object and call setFocus(field):

const {
  setFocus,
  formState: { errors },
  ...
} = useForm<FormValues>();

React.useEffect(() => {
  const firstError = Object.keys(errors).reduce((field, a) => {
    return !!errors[field] ? field : a;
  }, null);

  if (firstError) {
    setFocus(firstError);
  }
}, [errors, setFocus]);

Codesandbox Demo

Neuroglia answered 26/10, 2021 at 12:26 Comment(5)
As I wrote, if the error type is Object or Array, how can I handle it? Is there only one way to process them one by one as you wrote?Dislocate
@Dislocate did you open my codesandbox and try it out?Neuroglia
Yes. But I wonder if the type of error can't be handled as 'errors.name.firstname.message' instead of 'errors.firstname.message'.Dislocate
@Dislocate you're using RHF the wrong way, you should never have to control the form state yourself, if you're using nested component you should use useFormContext. I fixed your code in this codesandboxNeuroglia
isn't useEffect only triggered on first validation? as errors is still the same object, it won't trigger when its props are updatedMathildamathilde
R
9

What controls focus is the "ref" and for some reason when you use Controller the "ref" does not get associated properly to the underlying element, so you'll have to use ref from "register" instead of from "field".

import { FC } from 'react'
import { Controller, useFormContext } from 'react-hook-form'
import { TextField, TextFieldProps } from '@mui/material'

export const RhfTextField: FC<{ name: string } & TextFieldProps> = ({
  name,
  ...rest
}) => {
  const { control, register } = useFormContext()

  return (
    <Controller
      control={control}
      name={name}
      defaultValue=""
      render={({ field, fieldState: { error } }) => (
        <TextField
          {...rest}
          {...field}
          ref={register(name).ref} // overriding field.ref
          // you can do {...register(name)} and omit {...field}
          // but showing this explicitly for demonstration purposes
          error={!!error}
          helperText={error?.message ?? ''}
        />
      )}
    />
  )
}
Reiner answered 22/11, 2022 at 8:50 Comment(0)
T
8

Inspired by arcanereinz solution, you can use the inputRef without defining ref.

I think there is a bug in MUI, it's really odd this behaviour 🤷

Working codesandbox here https://codesandbox.io/s/sweet-panna-ttste0?file=/src/App.tsx

import { Controller, useForm } from "react-hook-form";
import TextField from "@mui/material/TextField";

export default function App() {
  const { handleSubmit, control } = useForm();
  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        name="foo"
        control={control}
        rules={{ required: true }}
        render={({ field: { ref, ...field }, fieldState }) => (
          <TextField
            fullWidth
            variant="outlined"
            error={Boolean(fieldState.error)}
            inputRef={ref}
            {...field}
          />
        )}
      />
      <button type="submit">submit</button>
    </form>
  );
}
Tallboy answered 25/11, 2022 at 15:33 Comment(0)
S
6

Based on @NearHuscarl answer, I create the typescript version:

React.useEffect(() => {
    const firstError = (
      Object.keys(errors) as Array<keyof typeof errors>
    ).reduce<keyof typeof errors | null>((field, a) => {
      const fieldKey = field as keyof typeof errors;
      return !!errors[fieldKey] ? fieldKey : a;
    }, null);

    if (firstError) {
      setFocus(firstError);
    }
  }, [errors, setFocus]);
Sanderson answered 20/5, 2022 at 11:23 Comment(2)
This doesn't work if you use mode: "onTouched" to validate because the errors don't change on submit. Adding isSubmitting to the dependency array seems to resolve it thoughAdventurism
Getting: Type '"root"' is not assignable to type '"firstName" | "lastName" | "email" | ...Underplay
H
0

I created a working example of how to set focus in case of errors with Controller

import { yupResolver } from '@hookform/resolvers/yup';
import type { ReactNode } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Controller, useForm, useFormState } from 'react-hook-form';
import { setTimeout } from 'timers';
import * as yup from 'yup';


const TextInput = (props: any) => {
  const [value, setValue] = useState(props.value || '');
  const inputRef = useRef<HTMLInputElement>(null); // Create a reference
  useEffect(() => {
    setValue(props.value);
  }, [props.value]); // watch props.value in case of change update it

  const { errors } = useFormState({
    control: props.control,
    name: props.name,
  });

  useEffect(() => {
    // Focus the input element if there's an error
    if (errors?.[props.name]) {
      inputRef.current?.focus();
    }
  }, [errors, props.name]);

   
  return (
    <>
      <input
        ref={inputRef} // Attach the reference to the input element
        name={props.name}
        onChange={(e) => {
          setValue(e.target.value);
          if (props.onChange) props.onChange(e); // we are sending down e.target.value here
        }}
        value={value}
        onBlur={props.onBlur}
      />
      <p>{errors?.[props.name]?.message as ReactNode}</p>
    </>
  );
};

const Test = () => {
  
  const {
    register,
    handleSubmit,
    control,
    setValue,
    // formState: { errors },
  } = useForm({
    resolver: yupResolver(
      yup.object().shape({
        firstName: yup.string().required('required'),
        lastName: yup.string().required('last name is required'),
      })
    ),
    defaultValues: {
      firstName: 'hello',
      lastName: 'world',
    },
  });
  const onSubmit = (data: any) => alert(JSON.stringify(data));

  // update component from outside
  useEffect(() => {
    setTimeout(() => {
      setValue('lastName', 'ddd');
    }, 1000);
  }, [setValue]);

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

      <Controller
        control={control}
        rules={{
          maxLength: 100,
          required: true,
        }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            placeholder="Last name"
            onBlur={onBlur}
            onChange={onChange}
            value={value}
            name={'lastName'}
            control={control}
          />
        )}
        name="lastName"
      />
      <input type="submit" />
    </form>
  );
};
export default Test;
Hereafter answered 20/7, 2023 at 13:24 Comment(0)
L
0

The accepted answer didn't work for me. Using ref was in the right direction. I feel I have to share that I found this didn't work:

              <Controller
                control={form.control}
                name="startDate"
                render={({
                  field: { onChange, onBlur, value },
                  fieldState: { error, invalid }
                }) => (
                  <TextField
                    inputRef={startDateRef}

But, rather I had to put the ref on the inputProps.

              <Controller
                control={form.control}
                name="startDate"
                render={({
                  field: { onChange, onBlur, value },
                  fieldState: { error, invalid }
                }) => (
                  <TextField
                    inputProps={ ref: startDateRef, ...}

And, then I could do this - I know my start/end date inputs are the only ones possibly erroring, so it was simpler to just:

  useEffect(() => {
    if (form.formState.errors) {
      if (form.formState.errors.startDate) {
        startDateRef.current?.focus();
      } else {
        endDateRef.current?.focus();
      }
    }
  }, [form.formState.errors, form.setFocus]);

NOTE: The particular setup for the Controller here, and the way I'm destructuring in the render is not the inconsequential to what I'm trying to share here. The point is using the inputProps->ref instead of inputRef was what finally got focus moved to the input with error.

Lifesaver answered 5/3 at 15:32 Comment(0)
M
-1
useEffect(() => {
    if (Object.keys(errors).length > 0) {
        const firstError = Object.keys(errors)[0] as keyof typeof errors;
        setFocus(firstError);
    }
}, [errors, setFocus]);
Mcmanus answered 7/12, 2022 at 15:10 Comment(2)
Do not post an answer with merely codes. While your solution can be useful, you should also explain why the code will fix the problem that was described in the question.Chancey
Solution is much cleaner as the accepted answer IMOUnderplay
M
-1

You can use setFocus from useForm hook as described below:

const {
  setFocus,
  formState: { errors },
  ...
} = useForm<FormValues>();

React.useEffect(() => {
  const firstError = Object.keys(errors).reduce((field, a) => {
    return !!errors[field] ? field : a;
  }, null);

  if (firstError) {
    setFocus(firstError);
  }
}, [errors, setFocus]);

In order to get the setFocus() work with Controller correctly you need to add the ref prop to controlled input like this:

export default function App() {
  const { handleSubmit, control } = useForm();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        name="foo"
        control={control}
        rules={{ required: true }}
        render={({ field: { ref } }) => (
          <TextField
            ...
            inputRef={ref}
          />
        )}
      />
      <button type="submit">submit</button>
    </form>
  );
}

Or to achieve the same thing with Select component from react-select:

<Controller
  control={control}
  name="states"
  render={({ field: { onChange, value, ref } }) => (
    <Select
      value={value}
      onChange={val => onChange(val)}
      options={states}
      placeholder="Choose states"
      ref={ref}
    />
  )}
/>;

Marlyn answered 12/5, 2023 at 10:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.