15
1

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.

metapsAdvent Calendar 2022

Day 15

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

Last updated at Posted at 2022-12-14

目的

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

要件

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

TL;DR

こんな感じでできた
スクリーンショット 2022-12-14 1.01.22.png

バージョン

  • Next.js 13.0.0
  • react-hook-form 7.35.0
  • mui 5.0.0系
  • swr 1.3.0
  • axios 0.27.2

実際のコード

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;

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で検索付きセレクトボックスを実現
  • MUIのDatePickerでdate pickerを実現
    • (dateFnsを入れなきゃいけなくてハマった)
  • MUI v5 から導入されたstyled()でCSS in JSを実現
  • 画像のinputをdisplay:noneにしてreactのhtmlForを使いボタンを別途用意
  • 画像URLを状態管理してプレビューを見せる

感想

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

参考にした記事

15
1
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
15
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?