WASD TECH BLOG

WASD Inc.のTech Blogです。

react-hook-formのSubmitHandlerを利用したonSubmit処理の返り値に型をつける

ワスド株式会社 開発チームのmako_jiiです。 今回は「react-hook-formのSubmitHandlerを利用したonSubmit処理の返り値に型をつける」と題しまして、記事を書かせていただこうかと思います。

なぜこの記事を書こうと思ったかというと、react-hook-formを使用した実装中で詰まったところがあったからです。

下記画像のように、useFormを使用する場合form部分のみ別コンポーネントに切り離して実装することが弊社では多いです。 form の状態管理にはreact-hook-formを使用しており、バリデーションにはclass-validatorを使用しています。

// Settings.tsx
import React from "react";
import { SettingsForm, OnSubmitSettingsFormValues } from "components/SettingsForm";

const Settings: React.FC = () => {
  const onSubmit: OnSubmitSettingsFormValues = async (data) => {
    await updateSettings(data); // DB更新処理など
  };

  return (
    <SettingsForm onSubmit={onSubmit} />
  );
};
// SettingsForm.tsx
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";

class SettingsFormValues {
  @IsNotEmpty({ message: "住所を入力してください" })
  address: string;

  @IsNotEmpty({ message: "名前を入力してください" })
  name: string;
}

export type OnSubmitSettingsFormValues = SubmitHandler<SettingsFormValues>;

export type SettingsFormProps = {
  onSubmit: OnSubmitSettingsFormValues;
};

const SettingsForm: React.FC<SettingsFormProps> = ({ onSubmit }) => {
  const { register, control, handleSubmit } = useForm<SettingsFormValues>();

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* テキストボックスとか */}
    </form>
  );
};

useFormform周りにまつわる部分のみ別コンポーネントに切り離しますが、
formで入力した内容に基づいたDBの更新処理などはメインのコンポーネントに置いておいて、引数でその関数を渡しています。
更新処理の関数の型は、formコンポーネントで作成した型にしております。

formの値が変更された時のみ保存ボタンを活性化させるために、DBに保存してある値でformの初期値を設定することに加え、
保存後に保存ボタンを非活性化させるために、保存後の値でformの初期値をresetさせる実装をしました。

// Settings.tsx
// onSubmit処理のみ記載
const onSubmit: OnSubmitSettingsFormValues = async (data) => {
  const { address, name } = await updateSettings(data); // DB更新処理など
  return { address, name }; // 返り値でオブジェクトを返す
};
// SettingsForm.tsx
// importや型定義は省略

const SettingsForm: React.FC<SettingsFormProps> = ({ onSubmit }) => {
  const { register, control, reset, handleSubmit } = useForm<SettingsFormValues>();

  // SettingsForm内でラップする
  const _onSubmit: OnSubmitSettingsFormValues = async (data) => {
    const result = await onSubmit(data);
    reset(result);  // anyを渡してしまい危険なコードになる
  };

  return (
    <form onSubmit={handleSubmit(_onSubmit)}>
      {/* テキストボックスとか */}
    </form>
  );  
};

がしかし、 コード内にも書いてあるとおりanyresetに渡ってしまい危険なコードになってしまいます。 なんでかというとreact-hook-formSubmitHandlerがそういう型定義をしているからです。

なんとか型推論させて安全にしてあげたい、
いろいろな解決方法があるかと思いますが、今回はプロパティの定義にclassを使用したためinstanceofを使って型を判定させることにしました。

developer.mozilla.org

// Settings.tsx
// onSubmit処理のみ記載
const onSubmit: OnSubmitSettingsFormValues = async (data) => {
  const { address, name } = await updateSettings(data); // DB更新処理など
  return { address, name }; // 返り値でオブジェクトを返す
};
// SettingsForm.tsx
// _onSubmit処理のみ記載

// SettingsForm内でラップする
const _onSubmit: OnSubmitSettingsFormValues = async (data) => {
  const result = await onSubmit(data);  // resultはオブジェクトであってインスタンスではないので、このif文はfalseになってしまう
  if(result instanceof SettingsFormValues) {
    reset(result);
  }
};

SettingFormValuesと同じ形のオブジェクトを渡して、型判定をinstanceofで行うようにコーディングしてみましたが、
コメントにも記載したようにif文の判定がfalseになってしまい意図した動作をしません。
調べた結果、値を返す時にオブジェクトを返しているのでSettingFormValuesクラスのインスタンスではないと判定されることが原因と分かりました。

そこで、プロパティを定義したclassconstructorを追加し初期値を載っけられるようにして、

// SettingsForm.tsx
// 処理なのは変更なしのため省略
export class SettingsFormValues {
  @IsNotEmpty({ message: "住所を入力してください" })
  address: string;

  @IsNotEmpty({ message: "名前を入力してください" })
  name: string;
  
  constructor(address:string, message:string) {
    this.address = addess;
    this.message = message;
  }
}

return newで返したい値をSettingFormValuesインスタンスで返すようにしました。

// Settings.tsx
// onSubmit処理のみ記載
const onSubmit = () => {
  const result = UpdateDBProcess(); // 更新処理
  return new SettingsFormValues(result.address, result.name);
};

うまくいきました!

返り値を扱っている時はanyですが、ifブロック内でresultは型推論されている状態で使用することができます。

以下最終形態です。

// Settings.tsx
import React from "react";
import {
  SettingsForm,
  OnSubmitSettingsFormValues,
  SettingsFormValues,
 } from "components/SettingsForm";

const Settings: React.FC = () => {
  const onSubmit: OnSubmitSettingsFormValues = async (data) => {
    const { address, name } = await updateSettings(data);  // DB更新処理など
    return new SettingsFormValues(address, name); // 返り値でインスタンスを返す
  };

  return (
    <SettingsForm onSubmit={onSubmit} />
  );
};
// SettingsForm.tsx
import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";

// Settings.tsxで使うのでexport
export class SettingsFormValues {
  @IsNotEmpty({ message: "住所を入力してください" })
  address: string;

  @IsNotEmpty({ message: "名前を入力してください" })
  name: string;

  // コンストラクタを定義
  constructor(address: string, name: string) {
    this.address = address;
    this.name = name;
  }
}

export type OnSubmitSettingsFormValues = SubmitHandler<SettingsFormValues>;

export type SettingsFormProps = {
  onSubmit: OnSubmitSettingsFormValues;
};

const SettingsForm: React.FC<SettingsFormProps> = ({ onSubmit }) => {
  const { register, control, handleSubmit } = useForm<SettingsFormValues>();

  // SettingsForm内でラップする
  const _onSubmit: OnSubmitSettingsFormValues = async (data) => {
    const result = await onSubmit(data);
    if(result instanceof SettingsFormValues) {
      reset(result);
    }
  };

  return (
    <form onSubmit={handleSubmit(_onSubmit)}>
      {/* テキストボックスとか */}
    </form>
  );
};

あるいは別解として、class-transformerというライブラリ中のplainToInstance関数を利用して、オブジェクトをクラスに変換することでも正しく動作します。
こちらの方がコード量も少なく、よりよい実装かもしれません。
この場合、classconstructorは不要となります。

github.com

// Settings.tsx
// onSubmit処理のみ記載
const onSubmit: OnSubmitSettingsFormValues = async (data) => {
  const { address, name } = await updateSettings(data);  // DB更新処理など
  return { address, name };
};
// SettingForms.tsx
// _onSubmit処理のみ記載

// SettingsForm内でラップする
const _onSubmit: OnSubmitSettingsFormValues = async (data) => {
  const result = await onSubmit(data);
  const values = plainToInstance(SettingsFormValues, result);
  if(values instanceof SettingsFormValues) {
    reset(result);
  }
};

かなり具体的なユースケースで発生した問題ですが、紐解いてみると解決にはTypeScriptのしっかりとした実力が求められ、改めて勉強になりました。
今後も、WASD TECH BLOGでは開発チームの取り組みや、 React, TypeScript, GraphQL, Hasura, AWS といった技術スタックについての記事を定期的に発信していきます。よろしければ過去記事や、 もよろしくお願いいたします。

以上、mako_jiiでした。