14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React Hook Form + Yupを使って複数画面にまたがるフォームを作成する

Posted at

React Hook Form

フォームの作成・バリデーションが簡単にできる便利なライブラリです。
Uncontrolled Components(非制御コンポーネント)を採用しており、stateの変更に伴う再レンダリングの数を減らし、アプリのパフォーマンスを向上させることができます。

制御コンポーネント

入力値をstateなどで保持するように制御されたもの

非制御コンポーネント

HTMLのinputやtextareaのように、入力値をDOM自身が保持しているもの

Yup

バリデーションのためのライブラリです。
JavaScriptで
スキーマを定義して値の解析・検証・変換などを行うことができます。


React Hook Formだけでもバリデーションを行うことは可能ですが、Yupを使用すればバリデーションに関する記述を一箇所にまとめたり、より複雑で表現力のあるバリデーションを行うことができます。

React Hook Formが標準でサポートしていて導入も簡単なので、今回はこちらを使用したいと思います。

やりたいこと

入力が2画面、確認用を1画面用意。
値を検証して問題がなければ、入力した値をまとめて送信

使い方

React Hook Formの基本的な使用方法

以下は公式から拝借したコードです。
registerを使用してinputなどの要素を登録し、検証ルールをReact Hook Formに適用することができます。

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

export default function App() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm();
  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input defaultValue="test" {...register("example")} />
      <input {...register("exampleRequired", { required: true })} />

      {errors.exampleRequired && <span>This field is required</span>}
      
      <input type="submit" />
    </form>
  );
}

registerを呼び出すことで、下記の参照をまとめて行うことができます

const { onChange, onBlur, name, ref } = register('firstName'); 
        
<input 
  onChange={onChange}
  onBlur={onBlur}
  name={name}
  ref={ref}
/>

// 同上
<input {...register('firstName')} />

Registerを使用したバリデーションは下記のように行うことができます。

<input
  {...register("test", {
    required: true
  })}
/>

<input
  {...register("test", {
    pattern: /[A-Za-z]{3}/
  })}
/>

簡単な使い方がわかったところで、実装していきましょう!

いざ実装

Yupを使ってスキーマを定義する

objectにスキーマを定義していきます。

src/utils/schema.ts
import { object, string, number } from 'yup';

const Schema = object({
  name: string().required('お名前の入力は必須です。'),
  age: number().typeError('年齢は数字を入力してください').integer('整数を入力してください'),
  email: string().email('メールアドレスの形式で入力してください。').required('メールアドレスの入力は必須です。'),
  phone: string().matches(/^[0-9]*$/, { message: '半角数字で入力してください。' }),
  favorite: string(),
});

export type SchemaType = InferType<typeof Schema>;

エラー時の文言は、それぞれの引数に設定しています。
スキーマはインターフェースを生成します。InferTypeはそのインターフェイスを抽出し、型生成を行ってくれます。

親ページの作成

今回はNextjsを利用しているため、pages配下に親となるページを作成します。

src/pages/form.tsx
import { yupResolver } from '@hookform/resolvers/yup';
import { Container, Typography } from '@mui/material';
import type { NextPage } from 'next'
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import Form1 from '../components/form1';
import Form2 from '../components/form2';
import Confirm from '../components/confirm';
import { Schema, SchemaType } from '../utils/schema';

const Form: NextPage = () => {
  const methods = useForm<SchemaType>({
    resolver: yupResolver(Schema),
    mode: 'onBlur',
  });
  const [pageNumber, setPageNumber] = useState(0);

  const onNext = () => {
    setPageNumber(state => state + 1);
  };

  const onPrev = () => {
    setPageNumber(state => state - 1);
  };

  return (
    <>
      <Container maxWidth="md" sx={{ marginTop: 7 }}>
        <Typography variant="h3" gutterBottom component="div">
          フォーム
        </Typography>
        <FormProvider {...methods}>
          <form>
            {pageNumber === 0 && <Form1 onNext={onNext} /> }
            {pageNumber === 1 && <Form2 onNext={onNext} onPrev={onPrev} /> }
            {pageNumber === 2 && <Confirm onPrev={onPrev} /> }
          </form>
        </FormProvider>
      </Container>
    </>
  )
}

export default Form;

ページ切り替えをuseStateで行ってますが、Layoutsとか他の方法でも問題ありません。

useFormのresolverを使用することでYup等、外部検証ライブラリを利用できます。
バリデーションはBlurのタイミングで行われるように設定しています。

<FormProvider {...methods}>
  // 〜〜〜
</FormProvider>

FormProviderは、 ReactのContext APIに基づいて構築されています。
これを使うことで、propsのバケツリレーを回避することができます。

入力画面の作成

src/components/form1.tsx
import { useFormContext } from "react-hook-form";
import { Box, Button, TextField } from '@mui/material';
import { Schema, SchemaType } from '../utils/schema';

type Props = {
  onNext: () => void
}

const Form1 = ({ onNext }: Props) => {
  const methods = useFormContext<SchemaType>();
  const {
    register,
    formState: { errors },
  } = methods;
  return (
    <>
      <TextField
        id="outlined-required"
        label="お名前"
        fullWidth
        {...register("name")}
        margin="normal"
        error={Boolean(errors.name)}
        helperText={errors.name?.message}
      />
      <TextField
        id="outlined-required"
        label="年齢"
        fullWidth
        margin="normal"
        {...register("age")}
        error={Boolean(errors.age)}
        helperText={errors.age?.message}
      />
      <TextField
        id="outlined-required"
        label="メールアドレス"
        fullWidth
        margin="normal"
        {...register("email")}
        error={Boolean(errors.email)}
        helperText={errors.email?.message}
      />
      <Box display="flex" justifyContent="center" gap={2} mt={2}>
        <Button variant="contained" onClick={onNext}>
          次へ
        </Button>
      </Box>
    </>
  )
}

export default Form1;

src/components/form2.tsx
import { Schema, SchemaType } from '../utils/schema';
import { useFormContext } from "react-hook-form";
import { Box, Button, TextField } from '@mui/material';

type Props = {
  onNext: () => void
  onPrev: () => void
}

const Form2 = ({ onNext, onPrev }: Props) => {
  const methods = useFormContext<SchemaType>();
  const {
    register,
    formState: { errors },
  } = methods;
  return (
    <>
      <TextField
        label="電話番号"
        variant="outlined"
        fullWidth
        {...register("phone")}
        margin="normal"
        error={Boolean(errors.phone)}
        helperText={errors.phone?.message}
      />
      <TextField
        label="好きな食べ物"
        variant="outlined"
        fullWidth
        {...register("favorite")}
        error={Boolean(errors.favorite)}
        helperText={errors.favorite?.message}
        margin="normal"
      />
      <Box display="flex" justifyContent="center" gap={2} mt={2}>
        <Button variant="contained" onClick={onPrev}>
          戻る
        </Button>
        <Button variant="contained" onClick={onNext}>
          確認
        </Button>
      </Box>
    </>
  )
}

export default Form2;

useFormContextで、ネストされたコンポーネントからReact Hook Formへ接続します。
先程呼び出したuseFormのメソッドを子要素のコンポーネントから簡単に呼び出すことができます。
propsを受け取る必要がないのでラクですね!

見た目はこのような感じです。
1ページ目↓
スクリーンショット 2022-11-29 11.27.27.png
2ページ目↓
スクリーンショット 2022-11-29 11.27.20.png

MaterialUIとの連携

入力フォームのスタイリングにはMaterialUIを使用しています。
ちなみにMaterialUIは「制御コンポーネント」です。

React Hook Formには、このような外部制御されているコンポーネントと簡単に統合を行うためのラッパーコンポーネントも提供されています。
Controller

RadioボタンやCheckboxを利用するときにはControllerを使う必要があるのですが、
TextFieldであれば普通にregisterを使用しても問題ありません。

Radioボタンなどを使いたいときには、Controllerを利用して下記のように対応できます。

<Controller
  name="fruit"
  control={control} 
  render={({ field }) => {
    return(
      <RadioGroup
        row
        { ...field }
      >
        <FormControlLabel value="apple" control={<Radio />} label="Apple" />
        <FormControlLabel value="orange" control={<Radio />} label="Orange" />
      </RadioGroup>
    );
  }}
/>

Controllerを使うのであれば、TextFieldもControllerで制御できるように記述方法を統一したほうがきれいかなーと思います。
しかしそうなると、いよいよ非制御コンポーネントの恩恵を受けられなくなってしまうのでは..!?って気もしますが、
React Hook Formは制御されたコンポーネントの再レンダリングも最適化をおこなってくれるようです。
非制御コンポーネントと混合された制御

確認画面の実装

src/components/confirm.tsx
import { useFormContext, SubmitHandler, SubmitErrorHandler } from "react-hook-form";

const Confirm = ({ onPrev }: Props) => {
  const methods = useFormContext<SchemaType>();
  const {
    handleSubmit,
    getValues,
    formState: { errors },
  } = methods;
  const [sendData, setSendData] = useState<SchemaType | null>(null);

  const onSubmit: SubmitHandler<SchemaType>  = data => {
    console.log('Success', data)
    setSendData(data)
  };
  
  const onErr: SubmitErrorHandler<SchemaType> = err => console.log('Error', err);

  useEffect(() => {
    handleSubmit(onSubmit, onErr)();
  }, [])

  return (
    <>
      <TableContainer>
        <Table>
          <TableBody>
            <TableRow>
              <TableCell component="th" scope="row">
                お名前
              </TableCell>
              <TableCell>
                {getValues('name')}
                <span style={{ color: 'red' }}>{errors.name?.message}</span>
              </TableCell>
            </TableRow>
            <TableRow>
              <TableCell component="th" scope="row">
                年齢
              </TableCell>
              <TableCell>
                {getValues('age')}
                <span style={{ color: 'red' }}>{errors.age?.message}</span>
              </TableCell>
            </TableRow>

            ~~繰り返す~~


          </TableBody>
        </Table>
      </TableContainer>

      <Box display="flex" justifyContent="center" gap={2} mt={2}>
        <Button variant="contained" onClick={onPrev}>
          戻る
        </Button>
        <Button
          variant="contained"
          onClick={() => alert(JSON.stringify(sendData))}
          disabled={!Boolean(sendData)}
        >
          送信
        </Button>
      </Box>
    </>
  )
}

export default Confirm;

値の表示は、getvaluesで行っています。

{getValues('age')}

エラー時には下記のerrorsオブジェクトにエラーの内容が入るので、それを表示しています。

formState: { errors },

表示はこのような感じです。
エラー時↓
スクリーンショット 2022-11-29 13.53.34.png

OK時↓
スクリーンショット 2022-11-29 11.32.15.png

初回レンダリング時にhandleSubmit(onSubmit, onErr)()を実行し、すべての値を検証しています。
バリデーションに問題がなければonSubmitが実行され、エラーがあればonErrが実行されます。

onSubmitでdataをuseStateに格納します。

  const onSubmit: SubmitHandler<SchemaType>  = data => {
    console.log('Success', data)
    setSendData(data)
  };

sendDataに値が入れば、送信ボタンが活性になります。
onClick内で送信すれば完了です。

まとめ

基本的な機能のみの使用ですが、簡単にフォームが作成できました。
どちらのライブラリもシンプルで使いやすく拡張性もあるので、入力が多くてもっと規模の大きいフォームだと開発スピードの面、パフォーマンス面でもかなり恩恵を受けられそうです。

利用者も多くWeb上にも情報がたくさんある&公式ドキュメントも見やすいので導入のハードルも低いところも良いと思います。

14
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?