Use react-aria with react-hook-form and Zod

Use react-aria with react-hook-form and Zod

I am making a form for my new project, and I want the form to have good accessability, validation, and ready made components that I just have to style a bit with Tailwind.

For accessability I found that react-aria from Adobe is pretty good, I am used to working with Chakra UI and Material UI (MUI) so the component style is making me feel at home.

react-hook-form with Zod gives me easy validation and typing of my form fields. Logrocket got a good blog on using these two together here.

"react-aria": "^3.19.0",
"@hookform/error-message": "^2.0.0",
"@hookform/resolvers": "^2.9.8",
"react-hook-form": "^7.35.0",
"zod": "^3.19.1"

But trying to use react-aria with react-hook-form and zod wasnt straight forward. The solution wasnt that complex, but I had to google alot and try many different thing before I understood what the problem was. Scroll to the bottom if you want the solution straight away.

I followed some guides and tried with the example code from react-aria and the example code for react-hook-form with Zod. Looking like this:

TextField.tsx
import { useTextField } from "react-aria";

function TextField(props) {
  let { label } = props;
  let ref = useRef();
  let { labelProps, inputProps, descriptionProps, errorMessageProps } =
    useTextField(props, ref);

  return (
    <div style={{ display: "flex", flexDirection: "column", width: 200 }}>
      <label {...labelProps}>{label}</label>
      <input {...inputProps} ref={ref} />
      {props.description && (
        <div {...descriptionProps} style={{ fontSize: 12 }}>
          {props.description}
        </div>
      )}
      {props.errorMessage && (
        <div {...errorMessageProps} style={{ color: "red", fontSize: 12 }}>
          {props.errorMessage}
        </div>
      )}
    </div>
  );
});
Form.tsx

import TextField from 'components/utils/TextField';
import { useForm, SubmitHandler } from "react-hook-form";
import { z, object, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ErrorMessage } from "@hookform/error-message";

export default function TestAria() {
  const zodSchema = object({
    title: z.string().min(4, { message: "Must be 5 or more characters long" }),
    description: z
      .string()
      .min(4, { message: "Must be 5 or more characters long" }),
  });
  type RegisterInput = TypeOf<typeof zodSchema>;

  const {
    register,
    formState: { errors, isSubmitSuccessful },
    handleSubmit,
  } = useForm<RegisterInput>({
    resolver: zodResolver(zodSchema),
  });

  const onSubmitHandler: SubmitHandler<RegisterInput> = (values) => {
    console.log({ values });
  };

  return (
    <form onSubmit={handleSubmit(onSubmitHandler)}>
      <div className="flex flex-col">
        <TextField
          label="Title"
          {...register("title")}
          validationState={!!errors.title?.message}
          errorMessage={errors.title?.message ?? ""}
        />
        <div className="flex flex-col">
          <label htmlFor="description">Description</label>
          <br />
          <textarea
            rows={5}
            aria-label="description"
            {...register("description")}
          />
          <ErrorMessage errors={errors} name="description" />
        </div>
        <button type="submit">Create</button>
      </div>
    </form>
  );
}

In Form.tsx I got the title input that is a react-aria component, and the description input that is a regular html input that works fine with react-hook-form and zod for comparison.

This didnt work, errormessages started popping up when I typed into the title input.

I am using Next.js and the first error that popped up in my console was this:

Warning: Prop `id` did not match. Server: "react-aria-5" Client: "react-aria-2"
label

This ment that the server side rendered part of Next.js and the client side rendered part got different ids on my title input. Googling the error said to use SSRProvider from react-aria to fix this.

_app.tsx

import { SSRProvider } from "react-aria";

function MyApp({ Component, pageProps: { ...pageProps } }: AppProps) {
  return (
        <SSRProvider>
          <Component {...pageProps} />
        </SSRProvider>
  );
}

export default MyApp;

Ok so that errormessage was gone, but a new one showed up:

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

So to use react-aria uncontrolled (not using react state) they use ref to keep control of which input field we are trying to type into. So the parent (Form.tsx) has to send in a ref for the child (TextField.tsx) to apply to the input field.

After googling and finding some issues on react-arias github page I tried different things mentioned in the issue-post, but what ended up working was using forwardRef (like the errormessage said :)) like this:

import { useTextField } from "react-aria";
import { forwardRef } from "react";

const TextField = forwardRef((props: any, ref: any) => {
  let { label } = props;
  // let ref = useRef();
  let { labelProps, inputProps, descriptionProps, errorMessageProps } =
    useTextField(props, ref);
  console.log({ inputProps }, { ref });

  return (
    <div style={{ display: "flex", flexDirection: "column", width: 200 }}>
      <label {...labelProps}>{label}</label>
      <input {...inputProps} ref={ref} />
      {props.description && (
        <div {...descriptionProps} style={{ fontSize: 12 }}>
          {props.description}
        </div>
      )}
      {props.errorMessage && (
        <div {...errorMessageProps} style={{ color: "red", fontSize: 12 }}>
          {props.errorMessage}
        </div>
      )}
    </div>
  );
});

export default TextField;

Well trying again and a new error message:

Uncaught (in promise) TypeError: target is undefined
    onChange index.esm.mjs:1738

It seemed to me that the change in the input didnt get registered or registered wrongly. Once again I had to google and found that react-hook-form has several other methods and properties in the {...register("title")} function.

const {
    onChange,
    onBlur,
    name,
    ref,
  } = register("title");

There we can see the ref that gets created and sent to the react-aria, along with the name and interestingly a onChange.

I console logged what react-hook-form got from the onChange

<TextField
            label="Title"
            {...register("title")}
            onChange={(e) => console.log({e})}
          />

What returned was just the string, if I typed "abc" the return was "abc".

I expected that the onChange from react-hook-form needs the format event.target.value (in TypeScript: ChangeEvent), and the error message said "target is undefined".

So I tried making a change to the onChange:

const onChangeHandler = (event) => {
    const e = { target: { event } };
    return e;
  };

<TextField
            label="Title"
            {...register("title")}
            onChange={(e) => register("title").onChange(onChangeHandler(e))}
          />

This way the onChange for react-hook-form would get the format event.target.value

And now everything worked!

Edit: I also found this bugreport that describes the problem, and a link to a discussion for why they wont change the behaviour of onChange.