SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

React Hook Form、Zod、Recoil を組み合わせたフォームを作る!

こんにちは、職人です!

スマートキャンプでBOXIL SaaSのエンジニアをやってます職人こと袴田です!
今回は新規会員登録の画面に関してUI/UXの向上のための施策を対応したことについて紹介します。

BOXIL SaaSとは

BOXIL SaaSはSaaSを導入したいユーザーとSaaSを提供しているベンダーをつなぐリボンモデルのプロダクトです。

新規のフォームを作る

BOXIL SaaSでは一部Reactを使用しています。

  • React Hook Form
  • Zod
  • Recoil

今回はこちらのライブラリを組み合わせてフォームを作成しました。

React Hook Formとはなんぞや

React Hook Formとは、Reactでformを作るときに便利なライブラリです。

基本的な使い方

useFormからregisterhandleSubmitを取得し、それぞれinputタグのpropsとsubmit時の処理に渡します。
registerはスプレッド構文で展開されて、onChangeやonBlurなどのイベントハンドラーに渡されます。

// react-hook-formからuserFormをimport
import { useForm } from "react-hook-form";

type FormValues = {
  firstName: string;
  lastName: string;
};

function MyForm() {
  // useFormからregisterとhandleSubmitを取得
  const { register, handleSubmit } = useForm<FormValues>();
  // submit時の処理を定義
  const onSubmit = (data: FormValues) => console.log(data);

  return (
    // onSubmitにhandleSubmitを渡す
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} />
      <input {...register("lastName")} />
      <input type="submit" />
    </form>
  );
}

Recoilとはなんぞや

Recoilとは、Reactで状態管理をするときに便利なライブラリです。

基本的な使い方

1.RecoilRootを設定する

RecoilRootを使用して、Recoilの状態を管理します。 下記の例ではAppコンポーネントに包括されるコンポーネントでRecoilの状態を取得できるようになります。 これが設定されていないと、Recoilの状態を取得できません。

import React from "react";
import ReactDOM from "react-dom";
import { RecoilRoot } from "recoil";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("exampleApp")
);

2.atomを定義する

atomを使用して、管理したい状態を個別に定義します。
atomとはアプリケーションの状態を管理するための単位と思っていただければいいと思います。

import { atom } from 'recoil';

export const countState = atom({
  key: 'countState',
  default: 0,
});

3.useRecoilStateで状態を読み書きする

useRecoilStateを使用して、値とセッターを取得できます。
セッターを使用し、値を更新できます。

import { useRecoilState } from 'recoil';
import { countState } from './atoms';

function Counter() {
  // countが値
  // setCountがセッター
  // というイメージ
  const [count, setCount] = useRecoilState(countState);

  const increment = () => {
    // セッターを使って、countを更新する
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

Zodとはなんぞや

zodとは、Reactでバリデーションをするときに便利なライブラリです。

基本的な使い方

z.objectを使用し、バリデーション実行対象の項目を設定したスキーマを定義します。

import { z } from "zod";

const schema = z.object({
  email: z.string().email().max(10),
  name: z.string().max(10),
});

type Data = z.infer<typeof schema>;
const data: Data = { name: "tarou", age: 20 };

React Hook Form & Recoil & Zod を組み合わせると

これらを組み合わせたフォームの作成例を簡単ではありますが、以下にまとめました。
フォームの状態、バリデーションなどはカスタムフック(useUserForm.ts)としてまとめており、MyForm側ではimportして使用しています。

useUserForm.ts

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { atom, useRecoilState } from "recoil";
import { z } from "zod";

const userSchema = z
  .object({
    email: z.string().nonempty().email().max(10),
    name: z.string().nonempty().max(10),
    company: z.string().nonempty()
  })

type UserForm = z.infer<typeof userSchema>;

const defaultValues: UserForm = {
  email: "",
  name: "",
  company: ""
}

const form = atom<UserForm>({
  key: "useUserFormAtom",
  default: defaultValues,
});

export const useUserForm = () => {
  const [formValues, setFormValues] = useRecoilState(form);
  const {
    register,
    handleSubmit,
    getValues,
    control,
    trigger,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(userSchema),
    mode: "onSubmit",
    defaultValues: formValues,
  });

  const handleSetFormValues = () => {
    console.log(JSON.stringify(getValues()))
    setFormValues(getValues());
  };

  return {
    handleSubmit,
    register,
    control,
    trigger,
    setFormValues,
    formValues,
    handleSetFormValues,
    errors
  };
};

MyForm.tsx

import { useUserForm } from "./useUserForm";
import { Controller } from "react-hook-form";
import Select from "react-select";

export function MyForm() {
  const userForm = useUserForm();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {  
    e.preventDefault();

    await userForm.trigger([
      "email",
      "name",
      "company"
    ]);

    userForm.handleSetFormValues();
  };

  const OPTIONS = [
    { label: "A社", value: "1", },
    { label: "B社", value: "2", }
  ]

  return (
    <form onSubmit={(e) => handleSubmit(e)}>
      {userForm.errors.email?.message && <p>{userForm.errors.email?.message}</p>}
      <input {...userForm.register("email")} />
      {userForm.errors.name?.message && <p>{userForm.errors.name?.message}</p>}
      <input {...userForm.register("name")} />
      {userForm.errors.company?.message && <p>{userForm.errors.company?.message}</p>}
      <Controller
        name="company"
        control={userForm.control}
        render={({ field }) => (
          <Select
            options={OPTIONS}
            value={OPTIONS.find(
              (option) => option.value === field.value
            )}
            {...userForm.register("company")}
            onChange={async (e) => e != null ? field.onChange(e.value) : null}
          />
        )}
      />
      <input type="submit" />
    </form>
  );
}

苦労したこと

ここからは実際にプロダクトに落とし込む際に試行錯誤したことをご紹介します。

入力した値がRecoilのステートに保存されない

例えば入力したフォームの値を次の画面やフォームに移動したときも保持したい場面があるかと思います。
しかし何も工夫せず画面遷移をしてしまうと、フォームに入力した値は保持できません。
Recoilの状態にも保存されていない状態になります。
Recoilを使用する場合はフォームに入力した値をRecoilの状態に保存するため、セッターを必ず呼ばなければいけません。

  const handleSetFormValues = () => {
    console.log(JSON.stringify(getValues())) // ここで入力した値を出力
    setFormValues(getValues());
  };
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {  
    // 省略
    userForm.handleSetFormValues();
  };

前述の例ではonSubmitが発火したときに、handleSetFormValuesを経由してsetFormValuesを呼び出しRecoilの状態に保存しています。

select boxのonChangeが機能しない

選択式の入力項目にはreact-selectを使用していました。
しかしReact Hook Formと組み合わせると、registerで展開されたonChangeは機能しなくなります。

react-selectはcontroledなコンポーネントであり、React Hook Formのregisterはuncontroledなコンポーネントにしか対応してないためです。

これを解消するためにReact Hook FormのControllerという機能を使用します。
Controllerでselect boxを囲い、renderの中でreact-selectを使用するとonChangeが機能するようになります。

<Controller
  name="company"
  control={userForm.control}
  render={({ field }) => (
    <Select
      options={OPTIONS}
      value={OPTIONS.find(
        (option) => option.value === field.value
      )}
      {...userForm.register("company")}
      onChange={async (e) => e != null ? field.onChange(e.value) : null}
    />
  )}
/>

今入力している項目以外の項目のバリデーションが実行されてしまう

React hook formではバリデーションの実行タイミングを指定できるモードがあります。
これはonChange, onBlur, onSubmitなどのモードがあり、デフォルトではonChangeになっています。
onChangeを使用している場合は、入力している項目の値を変更するとバリデーションが実行されますが、フォームに存在する他の入力項目に対しても実行されています。
これは今現在仕様のようですので、あきらめましょう。

解決策はonSubmitにすることです。

const {
  register,
  handleSubmit,
  getValues,
  control,
  trigger,
  formState: { errors },
} = useForm({
  resolver: zodResolver(userSchema),
  mode: "onSubmit", // ここをonSubmitにする
  defaultValues: formValues,
});

triggerを使って任意のタイミングでバリデーションを実行できます。
何かのボタンをクリックしたときにバリデーションを実行する場合は以下のような実装になります。

<button
  type="button"
  onClick={() => {
    userForm.trigger([
      "email",
      "name",
      "company"
    ]);
  }}
>

次回へ続く

今回はReact Hook Form、Zod、Recoilを組み合わせてフォームを作成したときに苦労したことを紹介しました。

実は今回紹介したこと以外にもまだ苦労したことがいくつかあります。
次回引き続きご紹介したいと思います!