🐙

ChakraUIとReact-Hook-Formで活用フォームコンポーネントを作る

2022/11/23に公開
1

モチベーション

https://chakra-ui.com/getting-started/with-hook-form

こちらは chakra 公式が紹介している RHF の使用法です

import { useForm } from 'react-hook-form';
import {
  FormErrorMessage,
  FormLabel,
  FormControl,
  Input,
  Button,
} from '@chakra-ui/react';

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

  function onSubmit(values) {
    return new Promise((resolve) => {
      setTimeout(() => {
        alert(JSON.stringify(values, null, 2));
        resolve();
      }, 3000);
    });
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FormControl isInvalid={errors.name}>
        <FormLabel htmlFor='name'>First name</FormLabel>
        <Input
          id='name'
          placeholder='name'
          {...register('name', {
            required: 'This is required',
            minLength: { value: 4, message: 'Minimum length should be 4' },
          })}
        />
        <FormErrorMessage>
          {errors.name && errors.name.message}
        </FormErrorMessage>
      </FormControl>
      <Button mt={4} colorScheme='teal' isLoading={isSubmitting} type='submit'>
        Submit
      </Button>
    </form>
  );
}

ラベルと入力欄の部分はここになります

抜粋
      <FormControl isInvalid={errors.name}>
        <FormLabel htmlFor='name'>First name</FormLabel>
        <Input
          id='name'
          placeholder='name'
          {...register('name', {
            required: 'This is required',
            minLength: { value: 4, message: 'Minimum length should be 4' },
          })}
        />
        <FormErrorMessage>
          {errors.name && errors.name.message}
        </FormErrorMessage>
      </FormControl>

これをname, email, password...のように幾つも並べると可読性が大きく下がってしまいます

ということで、これを活用コンポーネントにしてしまいます

完成したコンポーネント

components/Input/ContorolledInput.tsx
import {
  FormControl,
  FormLabel,
  Input,
  FormErrorMessage,
  InputProps,
  forwardRef,
  FormControlProps,
  FormLabelProps,
  FormErrorMessageProps,
} from '@chakra-ui/react';
import { FieldErrorsImpl, Ref } from 'react-hook-form';

export type ControlledInputProps = {
  label: string;
  errors: Partial<FieldErrorsImpl<Record<string, unknown>>>;
  name: string;
  ref: Ref;
  isRequired?: boolean;
  formControlProps?: Omit<FormControlProps, 'isInvalid' | 'isRequired'>;
  formLabelProps?: FormLabelProps;
  formErrorMessageProps?: FormErrorMessageProps;
} & Omit<InputProps, 'isRequired'>;

export const ControlledInput = forwardRef<ControlledInputProps, 'input'>(
  (
    {
      label,
      errors,
      name,
      isRequired,
      formControlProps,
      formLabelProps,
      formErrorMessageProps,
      ...rest
    }: Omit<ControlledInputProps, 'ref'>,
    ref
  ) => {
    return (
      <FormControl
        isInvalid={Boolean(errors[name])}
        isRequired={isRequired}
        {...formControlProps}
      >
        <FormLabel {...formLabelProps}>{label}</FormLabel>
        <Input name={name} {...rest} ref={ref} />
        <FormErrorMessage {...formErrorMessageProps}>
          {errors[name]?.message}
        </FormErrorMessage>
      </FormControl>
    );
  }
);

使用例

nextjsで書きました
import { Button, Container, Textarea } from '@chakra-ui/react';
import { zodResolver } from '@hookform/resolvers/zod';
import { NextPage } from 'next';
import { SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';
import { ControlledInput } from '../components/Input/ControlledInput';

export const schema = z.object({
  name: z.string().min(3, '名前は3文字以上で入力してください'),
  email: z.string().email('メールアドレスの形式が正しくありません'),
  password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
  description: z
    .string()
    .max(100, '自己紹介は100文字以内にしてください')
    .optional(),
});
export type FormValues = z.infer<typeof schema>;
const Page: NextPage = () => {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });
  const onSubmit: SubmitHandler<FormValues> = (form) => {
    console.log(form);
    reset();
  };
  return (
    <Container as='form' p='10' boxShadow='md' borderRadius='md'>
      <ControlledInput
        label='ユーザー名'
        errors={errors}
        isRequired
        {...register('name')}
      />
      <ControlledInput
        label='メールアドレス'
        type='email'
        errors={errors}
        isRequired
        {...register('email')}
      />
      <ControlledInput
        label='パスワード'
        errors={errors}
        isRequired
        type='password'
        {...register('password')}
      />
      <ControlledInput
        label='自己紹介'
        errors={errors}
        as={Textarea}
        {...register('description')}
      />
      <Button onClick={handleSubmit(onSubmit)}>送信</Button>
    </Container>
  );
};

export default Page;

出力

解説

コンポーネントについて

refをどうやって渡すか

RHF のregister

(name: string, RegisterOptions?) => ({ onChange, onBlur, name, ref })

の型になっています

ここにrefがあるのですが、そのまま props でref={ref}の様に渡せません。

https://zenn.dev/terrierscript/scraps/15ca11388f7424

なぜかはこちらの記事が参考になるかと思います

で、どうすればいいかというとforwordRefを使っていい感じに渡してもらいます

export const ControlledInput = forwardRef<ControlledInputProps, 'input'>(
  (
    {
      label,
      errors,
      name,
      isRequired,
      formControlProps,
      formLabelProps,
      formErrorMessageProps,
      ...rest
    }: Omit<ControlledInputProps, 'ref'>,
    ref
  ) => {
    return (
    ...

この部分です
props が(props, ref)という型で渡されるので、これを受け取ります。
あとはInputref={ref}で渡せば OK です

コンポーネントの利用

うまくコンポーネントを作成できたので、普段使用するようにコンポーネントを使用できます。

<ControlledInput
  label='ユーザー名'
  errors={errors}
  isRequired
  {...register('name')}
/>

errorslabelは独自のものですが、{...register('name')}という記法はいつものものですね。

また、InputProps中の属性typeを指定することでパスワードやメール、数値型も対応できます

type='password'
<ControlledInput
  label='パスワード'
  errors={errors}
  isRequired
  type='password'
  {...register('password')}
/>

それだけでなく、chakra のasを活用するとテキストボックスを扱えます

as={Textarea}
<ControlledInput
  label='自己紹介'
  errors={errors}
  as={Textarea}
  {...register('description')}
/>

かなり活用的なコンポーネントになりましたね。あとはデザインを凝るなどすればそれなりに使えるものになると思います

zod の利用

validation としてzodを使用しました

export const schema = z.object({
  name: z.string().min(3, '名前は3文字以上で入力してください'),
  email: z.string().email('メールアドレスの形式が正しくありません'),
  password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
  description: z
    .string()
    .max(100, '自己紹介は100文字以内にしてください')
    .optional(),
});

...

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  ...

zod を使用することにより、validation を jsx の箇所ではなく、外部で記述することができます
これにより、コンポーネントの構成がわかりやすくなります。

もちろん、バリデーションライブラリとしてもさまざまな機能が用意されているので、非常に使い勝手がいいです。
特に transform でフォームの値を変形できるのはめちゃくちゃいいですね

https://zenn.dev/uttk/articles/bd264fa884e026#transformer-について

こちらの記事がとてもわかりやすいです

最後に

ここまでご覧いただきありがとうございます。

本記事はベストプラクティスでもなんでもないですが、chakra と RHF のコンポーネント化の記事が見当たらないので作成してみました。

間違いやもっとこうした方がいいよ!等ありましたら、コメントや Twitter の DM で教えていただけると嬉しいです!

GitHubで編集を提案

Discussion

n4mlzn4mlz

とても良い記事をありがとうございます!
一つ訂正の提案なのですが、type ControlledInputPropsPartial<FieldErrorsImpl<Record<string, unknown>>>Partial<FieldErrors<Record<string, unknown>>>でないとエラーが出るようでした。
参考程度に、私の環境は以下です。

"react-hook-form": "^7.51.3",
"@hookform/resolvers": "^3.3.4",
"zod": "^3.23.4"

お役に立てたら嬉しいです🙇