🐣

React Hook Form、Zodで職務経歴書登録フォーム開発(フォーム編)

2023/10/04に公開

前回行ったこと

前回は React Hook Form、Zodで職務経歴書登録フォーム開発(準備編) としてフォーム開発の下準備を終わらせましました。今回は React Hook Form を利用したフォームを開発します。

今回使用したライブラリーのバージョン

  • Next.js: 13.4.19
  • React: 18.2.0
  • React Hook Form: 7.47.0
  • Zod: 次回導入
  • MUI: 5.14.11
  • TailwindCSS: 3.3.3
  • Recoil: 0.7.7
  • TypeScript: 5.2.2
  • Firebase: 10.4.0

今回行ったことまとめ

  • 個人情報を入力するフォームの作成
  • 入力した情報をFirestoreに保存する機能の開発(新規登録だけでなく更新も可能)

Zodはまだ使っていません!(タイトル詐欺2回目)

個人情報を入力するフォームの作成

React Hook Formを利用した基本的なフォームの開発

MUI v5とReact Hook Form v7でサクッとフォームバリデーションを作る に沿って進めました。

基本的にはuseFormを使って変数や関数を取得し・・・

import { useForm, SubmitHandler, Controller } from "react-hook-form";

const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
    control,
  } = useForm<PersonalInfo>();
  const onSubmit: SubmitHandler<PersonalInfo> = (data) => console.log(data);

その後は各フィールドに {...register("fullName")} というようなプロパティを追加するだけで済みます。React Hook Formを使わない場合は個別にuseStateで状態管理することになるので、それと比較すると楽ですね。

<TextField
  variant="outlined"
  label="Full name"
  required
  {...register("fullName")}
/>

Submitを行うボタンは以下のようになります。

<Button
  variant="contained"
  onClick={handleSubmit(onSubmit)}
>
  Save
</Button>

フォームへの初期値の設定

今回のフォームは新規登録も更新も可能にしたいです。そうすると更新の場合はフォームに初期値が設定されることになります。このときなりゆきではinput要素のラベル(Placehoder)と初期値が重なって表示されてしまいます。

これを避けるため React Hook Formでフォームに初期値を非同期で与える を参考に初期値設定後に状態を更新することにより再描画するよう実装しました。なお、 shouldUnregister はデフォルト値がfalseのようなのでパラメーター指定しませんでした。一応公式のドキュメントにも目を通したのですが、イマイチこのパラメーターの意味がわかっていません・・・。気になる方は↓からどうぞ。
https://react-hook-form.com/docs/useform#shouldUnregister

別の解決策としては、 React Material UI Label Overlaps with Text に書かれているようにラベルを初めからラベルとして表示する(Placeholderとしない)方法もあります。

<TextField
  // ... rest
  InputLabelProps={{ shrink: true }}  
/>

React Hook FormとMUIのDatePickerと組み合わせてみる

今回、生年月日を入力するためにMUIの DatePicker を利用してみました。DatePickerの場合は {...register("dateOfBirth")} という指定をするとエラーになってしまったためControllerを利用し以下の実装にしています。DatePickerには直接 required を指定できなかったので少し工夫しています。

<Controller
  name="dateOfBirth"
  control={control}
  render={({ field }) => (
    <DatePicker
      label="Date of birth"
      slotProps={{ textField: { required: true } }}
      {...field}
      onChange={(value) => field.onChange(value)}
    />
  )}
/>

補足:MUIでTailwindCSSを使う

フォーム開発とはあまり関係ないのですが、TailwindCSSも導入していたのでMUIでも sx 等でスタイルを指定するのではなく className にTailwindCSSのクラスを指定してみました。ただ当初は何も気にせず実装していたため、指定したTailwindCSSのクラスがMUIのクラスにより上書きされてしまっていました。これを避けるため Style library interoperability/Tailwind CSS のように <StyledEngineProvider injectFirst> を差し込む方法で対応しました。

ThemeRegistry.tsx
export default function ThemeRegistry(props: any) {
  // 省略
  return (
    <CacheProvider value={cache}>
      <StyledEngineProvider injectFirst>
        <ThemeProvider theme={theme}>
          <CssBaseline />
          {children}
        </ThemeProvider>
      </StyledEngineProvider>
    </CacheProvider>
  );
}

試してはいないですが、 Material UIをTailwind CSSのスタイルで上書きする方法 のようにtailwind.config.jsに important: true を追加する方法でも実現できそうです。

入力した情報をFirestoreに保存する機能の開発(新規登録だけでなく更新も可能)

DatePicker(dayjs)のデータをFirestoreに保存する

DatePickerを利用する場合の日付ライブラリーは選択可能ですが、今回はdayjsを利用しました。このとき入力した日付は文字列ではなく dayjs.Dayjs のインスタンスとなり、そのままFirestoreに保存することはできません。そのため format() により文字列に変換しています。

const ref = doc(database, "users", loginUser.userId);
const _personalInfo = {
  ...personalInfo,
  dateOfBirth: dayjs(personalInfo.dateOfBirth).format(),
};
await setDoc(ref, _personalInfo);

反対にFirestoreから初期値を取得しフォームに設定する際には次のようにして dayjs.Dayjs インスタンスにしています。

const personalInfoPromiss = getPersonalInfo(loginUser);
personalInfoPromiss
  .then((personalInfo) => {
    setValue<"dateOfBirth">(
      "dateOfBirth",
      dayjs(personalInfo?.dateOfBirth) || ""
    );
  });

開発環境かどうか判定する方法

開発環境の場合はFirebaseのエミュレーターを使いたいため、環境変数から環境を取得する実装をしていましたが、ViteとNext.jsではここの書き方も少し異なっていました。

// 開発環境ではemulatorに接続
// Viteのときは import.meta.env.DEV; と書いていた
const isDevelopment = process.env.NODE_ENV === "development";
if (isDevelopment) {
  connectAuthEmulator(auth, "http://localhost:9099");
  connectFirestoreEmulator(database, "127.0.0.1", 8080);
}

クライアントコンポーネント内の useRecoilState() がビルド時に動いてしまいログインユーザー情報を取得できない課題発生

当初次のような実装になっておりローカル環境では問題なく動いていたのですが、Firebaseにホスティングするとビルドのタイミングで useRecoilState() が動いてしまいログインユーザー情報を取得できませんでした。

"use client"

export function PersonalForm() {
  const [loginUser] = useRecoilState(userAtom);  // ビルド時に実行してしまいログインユーザーが取得できない

  useEffect(() => {
    const personalInfoPromiss = getPersonalInfo(loginUser);
    personalInfoPromiss
      .then((personalInfo) => {
        setValue<"fullName">(
          "fullName",
          personalInfo?.fullName || loginUser.userName || ""
        );
        setValue<"email">(
          "email",
          personalInfo?.email || loginUser.email || ""
        );
      });
  }, [loginUser, setValue, setMessageAtom]);

今回は妥協し useEffect() の中でローカルストレージに保管しておいたユーザー情報を取り出すことにしましたが、やはりサーバー/クライアントコンポーネント周辺の勉強が必要と実感しました。

なぜかFirebaseにデータ登録できない

ローカル環境では問題なく動いていたのですが、Firebaseにホスティングし、そこからFirebaseにデータ登録しようとするとうまくデータ登録できないことがありました。どうもキャッシュが効いていたようでブラウザの設定からキャッシュを削除するとうまく動きました。スーパーリロードではダメだったようです。

残課題:Firestoreの設定などが自動デプロイできていない

Firebaseにホスティングしてから気付きましたが、今の設定だと firestore.rules などはGitHub ActionsでFirebaseにデプロイはしておらず個別のデプロイが必要でした。これは今後の課題とします。

コード

https://github.com/shoji9x9/firebase-nextjs

アプリケーション

https://nextjs-a609c.web.app/

次回予定

次こそはZodでバリデーションを実装してタイトル回収します!

Discussion