Header image

クラッソーネの開発者がエンジニアリングに関することもそうでないことも綴っています!

React Hook Form の useFieldArray でフォームを作って MUI v5でスタイリングしてみた

React Hook Form の useFieldArray でフォームを作って MUI v5でスタイリングしてみた

こんにちは、プロダクト開発部の服部です。

もうすぐ12月、クリスマスまでのカウントダウンが始まりましたが、この時期の個人的恒例行事はクリスマスシュトーレンの仕込み作業です。
8月から赤ワインと蜂蜜に漬けてあるフルーツミックスと自家製マスカットレーズン酵母で今年も美味しいシュトーレンを焼きます!

さて、今回の記事ではMUI v5で追加された機能の一部を試すことを目的に、React Hook Formで簡単な商品金額フォームを作ってみたので紹介したいと思います。

https://mui.com/

完成イメージは次の通りです。

商品金額計算フォーム

MUI v5を試してみたい

私が現在開発に関わっているプロダクトではMaterial-UI v4を採用してスタイリングを行っていますが、そろそろMUI v5に移行したいと考えています。

v4 -> v5への移行手順が公式サイトで紹介されていました。

https://mui.com/guides/migration-v4/

MUI v5での大きな変更点

MUI v5へのバージョンアップによる大きな変更点のひとつに、v4で一般的だったmakeStylesを使ったスタイリング方法の基本的廃止(引き続き使えるが非推奨)があります。

公式ブログ:Introducing MUI Core v5.0 の記事内でも紹介されていますが、styled()を使ったコンポーネントのカスタマイズ方法は引き続き残るようです。

makeStylesやstyled()を利用したスタイリング
import { makeStyles } from '@material-ui/core';
import { Box, Button } from '@material-ui/core';

const useStyles = makeStyles({
  boxStyle: {
    color: 'white',
    backgroundColor: 'blue'
  }
});

const StyledButton = styled(Button)({
  color: 'white',
  backgroundColor: 'blue'
});

sx props

MUI v5で登場したsx propsを利用するとほとんどのMUIコンポーネントに直接スタイルを当てることが可能になり、単発で利用したいスタイルでもわざわざmakeStyles使ってスタイルを作る"あの手間"からも開放されそうです。個人的にはこの便利な機能を多用したいところ。。。

https://mui.com/system/the-sx-prop/

sx propsの利用例
<Box
  sx={{
    bgcolor: 'background.paper',
    boxShadow: 1,
    borderRadius: 2,
    p: 4,
    minWidth: 400,
  }}
>
  BOX
</Box>

<Button sx={{mr: 2}}>
  BUTTON
</Button>

とはいえ、公式サイトでもsx propsはパフォーマンスに影響すると言及されているので、単発のスタイリング以外では利用を避けた方がベターかも知れません。

https://mui.com/system/basics/#performance-tradeoff

React Hook Form の useFieldArray

React Hook Formは高性能で柔軟かつ拡張可能なフォームバリデーションライブラリで、バリデーションルールは全てHTML標準に基づいており、カスタムバリデーションも可能です。

React Hook Formのregisterを利用してMUIのTextField(formやinput)と接続し、useFieldArray(uncontrolledな配列の入力フィールドを操作するためのフック)にnamecontrolを渡すことで、MUIのTextFieldへの入力値を取得できるようになります。
さらに公式サイトでも紹介されているerror-messageを利用したバリデーションを実装してみました。

https://react-hook-form.com/api/usefieldarray

https://react-hook-form.com/v6/api#ErrorMessage

CodeSandbox上で開発環境の準備をする

それでは、React Hook FormのuseFieldArrayを利用したフォームを作ってMUI v5でスタイリングしていきます。
CodeSandboxのReact TypeScriptテンプレートから新しいSandBoxを作成し、 @mui/material @mui/icons-materialreact-hook-form @hookform/error-messageをdependenciesに追加します。

またemotionを利用したいので、@mui/material @emotion/react @emotion/styledも追加します。

CodeSandbox上に作成したmui-react-hook-form-test/srcのファイル構成は次の通りです。

mui-react-hook-form-test
└── src
    ├── index.tsx
    ├── FormValues.ts
    ├── TotalAmount.tsx
    └── App.tsx

index.tsx

CssBaselineを利用するとグローバルでcssをリセットしてくれます。

index.tsx
import { render } from "react-dom";
import {CssBaseline} from '@mui/material';

import App from "./App";

const rootElement = document.getElementById("root");
render(
  <>
    <CssBaseline />
    <App />
  </>,
  rootElement
);

https://mui.com/components/css-baseline/#global-reset

CssBaselineではなくScopedCssBaselineを利用するとcssのリセットがグローバルではなく子にのみ適用され、v4などからv5への段階的な移行を可能にしてくれます。

https://mui.com/components/css-baseline/#scoping-on-children

FormValues.ts

formのvaluesの型を定義しています。

FormValues.ts
export type FormValues = {
  name: string;
  items: {
    name: string;
    price: number;
    quantity: number;
  }[];
};

TotalAmount.tsx

計算結果を表示させるTotalAmountコンポーネントです。

TotalAmount.tsx
import { Typography } from "@mui/material";
import { Control, useWatch } from "react-hook-form";
import { FormValues } from "./FormValues";

type Props = {
  control: Control<FormValues>;
};

export const TotalAmount = ({ control }: Props): JSX.Element => {
  const formValues = useWatch({
    // useWatch 関数は items を監視しその値を返す
    name: "items",
    control
  });
  const total = formValues.reduce(
    (acc, { price, quantity }) =>
      acc + Math.floor((price || 0) * (quantity || 0)),
    0
  );

  const tax = Math.floor(total * 0.1);
  return (
    // MUI v5 から Box 以外では、Typography、Stack、Grid コンポーネントでシステムプロパティーを受け入れるようになった
    <Typography mt={4}>
      合計: {total + tax}<small>({tax})</small>
    </Typography>
  );
};

App.tsx

App.tsx
import React from "react";

// 利用したい MUI コンポーネントを import
import {
  Box,
  Button,
  Container,
  TextField,
  Stack,
  IconButton
} from "@mui/material";

// 利用したい React Hook Form のフックをimport
import { useForm, useFieldArray } from "react-hook-form";
import {
  Add as AddIcon,
  DeleteOutline as DeleteOutlineIcon
} from "@mui/icons-material";

// React Hook Form でエラーメッセージを表示するための ErrorMessage コンポーネントを import
import { ErrorMessage } from "@hookform/error-message";

// 計算結果を表示させる TotalAmount コンポーネントをimport
import { TotalAmount } from "./TotalAmount";
import { FormValues } from "./FormValues";

export const App = (): JSX.Element => {
  const {

    // register 関数はinput/select の Ref とバリデーションルールを React Hook Form に登録 (register)
    register,

    // reset 関数はフォーム内のフィールドの値とエラーをリセットできる
    reset,
    control,

    // handleSubmit 関数はバリデーションに成功するとフォームデータを渡す
    handleSubmit,

    // errors オブジェクトには、各 input のフォームのエラーまたはエラーメッセージが含まれる
    // バリデーションとエラーメッセージで登録するとエラーメッセージが返される
    formState: { errors }
  } = useForm<FormValues>({

    // defaultValues を省略可能な引数として渡してフォーム全体のデフォルト値を設定し、fields 配列に値を格納
    // defaultValues はカスタムフック内にキャッシュされる
    // defaultValues は reset API でリセットできる
    defaultValues: {
      name: "",
      items: [{ name: "", quantity: 0, price: 0 }]
    },

    // blur イベントからバリデーションがトリガーされる
    mode: "onBlur"
  });

  // useFieldArray に name と control を渡すことで、MUI の TextField への入力値を取得できるようになる
  const { fields, append, remove } = useFieldArray({
    name: "items",
    control
  });

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

  return (
    <Container maxWidth="sm" sx={{ pt: 5 }}>

      {/* Stack コンポーネントは MUI v5 で追加された新しいコンポーネント https://mui.com/components/stack/ */}
      <Stack spacing={2}>
        <TextField
          label="商品名カテゴリ"

          // name 属性は必須かつ unique
          {...register("name", {
            required: true
          })}
          size="small"
        />
        <Box color="error.main" fontSize={12}>
          <ErrorMessage
            errors={errors}
            name="name"
            as="p"
            message="⚠ 商品カテゴリを入力してください"
          />
        </Box>
        {fields.map((field, index) => {
          return (

            // 必ず fields オブジェクトの id をコンポーネントの key に割り当てる
            <React.Fragment key={field.id}>
              <Box display="flex">
                <TextField
                  sx={{ mr: 2, flex: 3 }}
                  size="small"
                  label="商品名"

                  // arrays/array フィールドを使用する場合、input の name 属性を name[index] のように割り当てることができる
                  {...register(`items.${index}.name`, {
                    required: true
                  })}
                />
                <TextField
                  sx={{ mr: 2, flex: 1 }}
                  size="small"
                  type="number"
                  label="単価"
                  {...register(`items.${index}.price`, {
                    min: 1,
                    max: 10000
                  })}
                />
                <TextField
                  sx={{ mr: 1, flex: 1 }}
                  size="small"
                  type="number"
                  label="数量"
                  {...register(`items.${index}.quantity`, {

                    // input が受け付ける最小文字数と最大文字数を設定
                    min: 1,
                    max: 100
                  })}
                />

                {/* remove 関数は特定の位置の input を削除、位置を指定しない場合は全てを削除 */}
                <IconButton aria-label="delete" onClick={() => remove(index)}>
                  <DeleteOutlineIcon />
                </IconButton>
              </Box>
              <Box color="error.main" fontSize={12}>

                {/* ErrorMessage コンポーネントは name 属性で関連付けされた入力のエラーメッセージを表示するためのシンプルなコンポーネント */}
                <ErrorMessage
                  errors={errors}
                  name={`items.${index}.name`}
                  message="⚠ 商品名を入力してください"
                  as="p"
                />
                <ErrorMessage
                  errors={errors}
                  name={`items.${index}.price`}
                  message="⚠ 単価欄に1~10000の数字を入力してください"
                  as="p"
                />
                <ErrorMessage
                  errors={errors}
                  name={`items.${index}.quantity`}
                  message="⚠ 数量欄に1~100の数字を入力してください"
                  as="p"
                />
              </Box>
            </React.Fragment>
          );
        })}
      </Stack>
      <Button

        // MUI v5 から sx props で Button コンポーネントにも直接スタイルを書けるようになった
        sx={{ mt: 1 }}
        startIcon={<AddIcon />}

        // append 関数はフィールドの最後に input を追加する
        onClick={() =>
          append({
            name: "",
            quantity: 0,
            price: 0
          })
        }
      >
        行を追加する
      </Button>
      <Box textAlign="center" mt={2}>
        <Button
          variant="outlined"
          sx={{ mr: 1 }}
          onClick={() =>
            reset({
              name: "",
              items: [{ name: "", quantity: 0, price: 0 }]
            })
          }
        >
          リセット
        </Button>
        <Button
          color="primary"
          variant="contained"
          disableElevation
          // submit イベントからバリデーションがトリガーされる
          onClick={handleSubmit(onSubmit)}
        >
          送信
        </Button>
      </Box>
      <Box textAlign="right">
        <TotalAmount control={control} />
      </Box>
    </Container>
  );
};

バリデーションエラーによる個々のエラーメッセージも表示できました😊

バリデーションエラー

コードはCodeSandboxに置いておきます。

React Hook FormとMUI v5を上手く連携させて効率の良いフォーム実装を実現させていきたいと思います!

参考サイト:

おわりに

クラッソーネでは、プロダクトとチームの双方をより良く改善していけるエンジニアを大募集中です。
興味を持っていただけた方、ぜひ私と一度カジュアル面談でお話ししましょう!

https://www.crassone.co.jp/recruit/engineer/