React Hook Form の useFieldArray でフォームを作って MUI v5でスタイリングしてみた
こんにちは、プロダクト開発部の服部です。
もうすぐ12月、クリスマスまでのカウントダウンが始まりましたが、この時期の個人的恒例行事はクリスマスシュトーレンの仕込み作業です。
8月から赤ワインと蜂蜜に漬けてあるフルーツミックスと自家製マスカットレーズン酵母で今年も美味しいシュトーレンを焼きます!
さて、今回の記事ではMUI v5で追加された機能の一部を試すことを目的に、React Hook Formで簡単な商品金額フォームを作ってみたので紹介したいと思います。
完成イメージは次の通りです。
MUI v5を試してみたい
私が現在開発に関わっているプロダクトではMaterial-UI v4を採用してスタイリングを行っていますが、そろそろMUI v5に移行したいと考えています。
v4 -> v5への移行手順が公式サイトで紹介されていました。
MUI v5での大きな変更点
MUI v5へのバージョンアップによる大きな変更点のひとつに、v4で一般的だったmakeStyles
を使ったスタイリング方法の基本的廃止(引き続き使えるが非推奨)があります。
公式ブログ:Introducing MUI Core v5.0 の記事内でも紹介されていますが、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
使ってスタイルを作る"あの手間"からも開放されそうです。個人的にはこの便利な機能を多用したいところ。。。
<Box
sx={{
bgcolor: 'background.paper',
boxShadow: 1,
borderRadius: 2,
p: 4,
minWidth: 400,
}}
>
BOX
</Box>
<Button sx={{mr: 2}}>
BUTTON
</Button>
とはいえ、公式サイトでもsx props
はパフォーマンスに影響すると言及されているので、単発のスタイリング以外では利用を避けた方がベターかも知れません。
React Hook Form の useFieldArray
React Hook Formは高性能で柔軟かつ拡張可能なフォームバリデーションライブラリで、バリデーションルールは全てHTML標準に基づいており、カスタムバリデーションも可能です。
React Hook Formのregister
を利用してMUIのTextField(formやinput)と接続し、useFieldArray(uncontrolledな配列の入力フィールドを操作するためのフック)にname
とcontrol
を渡すことで、MUIのTextFieldへの入力値を取得できるようになります。
さらに公式サイトでも紹介されているerror-message
を利用したバリデーションを実装してみました。
CodeSandbox上で開発環境の準備をする
それでは、React Hook FormのuseFieldArrayを利用したフォームを作ってMUI v5でスタイリングしていきます。
CodeSandboxのReact TypeScript
テンプレートから新しいSandBoxを作成し、 @mui/material
@mui/icons-material
、react-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をリセットしてくれます。
import { render } from "react-dom";
import {CssBaseline} from '@mui/material';
import App from "./App";
const rootElement = document.getElementById("root");
render(
<>
<CssBaseline />
<App />
</>,
rootElement
);
CssBaseline
ではなくScopedCssBaseline
を利用するとcssのリセットがグローバルではなく子にのみ適用され、v4などからv5への段階的な移行を可能にしてくれます。
FormValues.ts
formのvaluesの型を定義しています。
export type FormValues = {
name: string;
items: {
name: string;
price: number;
quantity: number;
}[];
};
TotalAmount.tsx
計算結果を表示させるTotalAmountコンポーネントです。
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
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を上手く連携させて効率の良いフォーム実装を実現させていきたいと思います!
参考サイト:
おわりに
クラッソーネでは、プロダクトとチームの双方をより良く改善していけるエンジニアを大募集中です。
興味を持っていただけた方、ぜひ私と一度カジュアル面談でお話ししましょう!