Next.js + react-hook-form + MUI でフォーム作成

# 目的
個人開発しているWebアプリでNext.jsを使っています。
ある日検索機能付きセレクトボックスとdatepickerがあるフォームを作りたい場面に出会いました。
Tailwind.cssを使おうと思ったのですが検索機能付きセレクトボックスを手っ取り早く実装できなさそう...
ということでMUIを使うことに...!

# 要件
- 表示前に複数エンドポイントからデータ取得
- 検索機能付きセレクトボックス
- datepicker
- 画像送信


# TL;DR
こんな感じでできた
![スクリーンショット 2022-12-14 1.01.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/305682/ce3c41cb-48db-67d2-9f16-bdab9ab219bd.png)

# バージョン
- Next.js 13.0.0
- react-hook-form 7.35.0
- mui 5.0.0系
- swr 1.3.0
- axios 0.27.2

# 実際のコード

```react:form.tsx
import { useState } from 'react';
import type { NextPage } from 'next';
import { useRouter } from 'next/router'

import axios from 'axios';
import useSWR from 'swr';
import getMultipleFetcher from '@/api/fetchers/getMultipleFetcher';

import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import { Stack, Autocomplete, TextField, Button } from '@mui/material';
import { styled } from '@mui/material/styles'
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';

type Inputs = {
  user: {};
  shop: {};
  image: File;
  date: Date;
};

const LabelStyle = styled('span')({
  color: "rgba(0, 0, 0, 0.6)",
  fontSize: 15,
  marginRight: 15
});

const Form: NextPage = () => {
  const baseURI = process.env.NEXT_PUBLIC_BASE_URL;

  const usersURI = `${baseURI}/users`;
  const shopsURI = `${baseURI}/shops`;

  const urls = [usersURI, shopsURI];
  const { data, error } = useSWR(urls, getMultipleFetcher);
  const [currentImage, setImage] = useState(new Blob)
  const [imageUrl, setImageUrl] = useState("")

  const { control, handleSubmit, setValue } = useForm<Inputs>({
    defaultValues: { date: new Date() },
  });

  const validationRules = {
    user: {
      required: 'user is required',
    },
    shop: {
      required: 'shop is required',
    },
    date: {
      validate: (val: Date | null) => {
        if (val == null) {
          return 'date　is required';
        }
        if (Number.isNaN(val.getTime())) {
          return 'invalid date';
        }
        return true;
      },
    },
  };

  if (error) return <div>An error has occurred.</div>;
  if (!data) return <div>Loading...</div>;

  const router = useRouter()
  const onSubmit: SubmitHandler<Inputs> = async (data: Inputs) => {
    const { shop, user, date } = data;
    const formData = new FormData();

    formData.append("shop", shop.id);
    formData.append("user", user.id);
    formData.append("date", date?.toString());
    formData.append('image', currentImage, currentImage.name)

    axios({
      url: `${baseURI}/register`,
      method: "post",
      data: formData,
      headers: {
        "content-type": "multipart/form-data",
      },
    })
      .then(() => router.push("/"))
      .catch((error) => {
        alert("エラーが発生しました。");
      });
  };
  return (
    <LocalizationProvider dateAdapter={AdapterDateFns}>
      <Stack
        component='form'
        noValidate
        onSubmit={handleSubmit(onSubmit)}
        spacing={2}
        sx={{ m: 2, width: '80ch' }}
      >
        <Controller
          control={control}
          name='user'
          rules={validationRules.user}
          render={({ props }) => (
            <Autocomplete
              fullWidth
              options={data[0]}
              renderInput={(params) => <TextField {...params} label='user' />}
              onChange={(event, value) => {
                setValue('user', value, {
                  shouldValidate: true,
                  shouldDirty: true,
                  shouldTouch: true,
                });
              }}
            />
          )}
        />
        <Controller
          control={control}
          name='shop'
          rules={validationRules.shop}
          render={({ props }) => (
            <Autocomplete
              fullWidth
              options={data[1]}
              getOptionLabel={(option) => option?.name}
              renderInput={(params) => <TextField {...params} label='shop' />}
              onChange={(event, value) => {
                setValue('shop', value, {
                  shouldValidate: true,
                  shouldDirty: true,
                  shouldTouch: true,
                });
              }}
            />
          )}
        />
        <Controller
          name='date'
          control={control}
          rules={validationRules.date}
          render={({ field, fieldState }) => (
            <DatePicker
              mask='____/__/__'
              inputFormat='yyyy/MM/dd'
              label='Date'
              renderInput={(params) => (
                <TextField
                  {...params}
                  helperText={fieldState.error?.message}
                />
              )}
              {...field}
            />
          )}
        />
        <Controller
          name='image'
          control={control}
          render={({ field, fieldState }) =>
            <label htmlFor={"image"}>
              <LabelStyle>
                <span>Image</span>
              </LabelStyle>
              <Button variant='outlined' component="span">
                SELECT
              </Button>
              <input type='file' id="image" style={{ display: 'none' }} onChange={e => {
                setImage(e.target.files[0])
                setImageUrl(URL.createObjectURL(e.target.files[0]))
              }} />
              <img src={imageUrl} style={{ marginTop: 20 }} />
            </label>
          }
        />
        <Button variant='contained' type='submit' color="info">
          Submit
        </Button>
      </Stack>
    </LocalizationProvider >
  );
};

export default Form;

```


```typescript:getMultipleFetcher.ts
import axios from 'axios';

export const getMultipleFetcher = (...urls: string[]) => {
  const getRequest = (url: string) => axios.get(url).then((res) => res.data);
  return Promise.all(urls.map((url) => getRequest(url)));
};

export default getMultipleFetcher;
```



# コード解説

### 通信
- 表示前のデータ取得はswrを使って複数のエンドポイントからデータを取得してPromise.allで解決
- postは素のaxiosでformDataにappendしまくる

### 描画
- MUIの[Autocomplete](https://mui.com/material-ui/react-autocomplete/)で検索付きセレクトボックスを実現
- MUIの[DatePicker](https://mui.com/x/api/date-pickers/date-picker/#main-content)でdate pickerを実現
    - （dateFnsを入れなきゃいけなくてハマった）
- MUI v5 から導入された[styled()](https://mui.com/system/styled/)でCSS in JSを実現
- 画像のinputを`display:none`にしてreactの`htmlFor`を使いボタンを別途用意
- 画像URLを状態管理してプレビューを見せる

# 感想
- 調べながらやればそんなに難しくない！
- MUIはv5になってcssの記法が増えて面白いなあと思いました。
- 業務はサーバーサイドの保守が多いのでフロントの開発楽しいと感じました。
- 型をもっとちゃんとしたい....

## 参考にした記事
- https://zenn.dev/longbridge/articles/640710005e11b1
- https://zenn.dev/kimura141899/articles/6e11e3a165460d
- https://qiita.com/pkino/items/79a18a0bd518bf2660bd
- https://zenn.dev/enish/articles/a2314fc2c00cf7