form内の複数のsubmitボタンを区別する方法とFormEventの型定義

form内にsubmitボタンが複数あって、どっちのsubmitボタンが押されたかを区別したい時ってありませんか?

たとえば「保存して詳細画面に戻る」と「保存して次の入力に進む」みたいなケース
こういうケースで1つのformの中に複数のsubmitボタンがあるときにどちらのsubmitボタンが押されたのかを判別する方法をまとめておきます。

調査する過程でReactの型定義周りの挙動についても勉強になったので、こちらもあわせて記載します。
どちらかと言うとこちらが本記事の主旨です。

目次

submitterで判別する

早速結論を書くと以下のようにすればどちらのsubmitボタンが押されたのかを判別できます。

<form onSubmit={e => {
  const submitButton = e.nativeEvent.submitter?.name
  if (submitButton === "backToDetail") {
    location.href = "foo"
    return
  }
  if (submitButton === "goToNext") {
    location.href = "bar"
    return 
  }
}}>
  <button name="backToDetail">保存して詳細画面に戻る</button>
  <button name="goToNext">保存して次の入力へ進む</button>
</form>

nativeEventについてはこちらを参照

ただし、当然submitButtonはリテラル型ではないので型安全ではないので注意です。

それぞれのbuttonのonClickで処理を書き分ければ済む話では?という感じなのですが、submit時にHTMLのformのバリデーションを適用させたいのでこの形にしています。

型定義を追う

実は型定義については先ほどの部分以外にも型安全じゃない箇所があります。
それがsubmitterとnameの部分です。実際この両者は補完で出てきません。

以下ではこうなってる理由を追っていきます。

まずformのonSubmitのcallbackで受け取る引数のeventの型はFormEvent<HTMLFormElement>となっています。
FormEventの型を見ると以下のようにSyntheticEventをextendsしていて、それはBaseSyntheticEventをextendsしています。

interface FormEvent<T = Element> extends SyntheticEvent<T> {}

interface SyntheticEvent<T = Element, E = Event> extends BaseSyntheticEvent<E, EventTarget & T, EventTarget> {}

interface BaseSyntheticEvent<E = object, C = any, T = any> {
  nativeEvent: E;
  ...
}

SyntheticEventの型パラメータの2番目は何も与えられていないので、BaseSyntheticEventの1番目はただのEventが与えられます。
これによってnativeEventの型がEventになっています。

今回使用したいsubmitterSubmitEventにあるものなので補完が効いていない原因はここにありそうです。

他のEventの型を追ってみると、同定義ファイルにNativeFooEventなるものが多数定義されていて、@types/react/global.d.tsの型定義を参照していて、そこには以下のような記載がありました。

React projects that don't include the DOM library need these interfaces to compile.
React Native applications use React, but there is no DOM available. The JavaScript runtime
is ES6/ES2015 only. These definitions allow such projects to compile with only `--lib ES6`.

Warning: all of these interfaces are empty. If you want type definitions for various properties
(such as HTMLInputElement.prototype.value), you need to add `--lib DOM` (via command line or tsconfig.json).

interface Event { }
interface AnimationEvent extends Event { }
...

つまりtsconfigのlibでDOMを指定していれば、そこから型情報を読み取るということらしいです。
設定した上で、ここにSubmitEvent を追加してあげればlib.dom.d.tsSubmitEventを参照するようになります。
この状態で先ほどのSyntheticEventのEのデフォルト値をSubmitEventにすると、submitterは補完されるようになりました。

type NativeSubmitEvent = SubmitEvent;

...

interface FormEvent<T = Element> extends SyntheticEvent<T, NativeSubmitEvent> {}

しかし、今度はnameが補完されません。
SubmitEventの中を見てみると以下のようになっています。

interface SubmitEvent extends Event {
    /** Returns the element representing the submit button that triggered the form submission, or null if the submission was not triggered by a button. */
    readonly submitter: HTMLElement | null;
}

Returns the element representing the submit button that triggered the form submissionと書いてるので、HTMLElementではなくnameプロパティのあるHTMLButtonElementを型定義にしてあげれば補完が効くようになりました。

PRを出してみる

せっかくなのでここまでの内容をPR出すかと思って作ってみたんですが、結論うまくいきませんでした

というのもすでに現状のFormEventが色々な型定義で参照されているため、他のpackageでは型の整合性が取れずテストが落ちまくりました…修正するのも憚られる量だったので諦めました

submitterの方の型定義だけでも修正しようかと思ったんですが、こちらも修正までの道のりが険しそうな気がして断念しました

すでに他のいろいろなライブラリで使用されているので破壊的変更になってしまうので放置されているのだと思ってます

型定義を拡張する

現状型定義がうまく補完されず怒られるので、以下のようにしてしまっています

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const submitName = e.nativeEvent.submitter?.name

一応アンビエントモジュールを使ってSubmitEventを使うFormEventを定義してそれを使うようにすればsubmitterの補完は効くようになります。

が、今回はsubmitNameはリテラル型として扱いたく、結局補完させたところでstring型にしかならないのでasでキャストすることになりそうです。

そこまでする程のことでもないかと思い、今回は元の形のまま@ts-ignoreする形に落ち着きました

まとめ

submit時にformのバリデーションを効かせつつ、どの保存ボタンが押されたかを判別するためにはe.nativeEvent.submitter?.nameを使うとよい

やりたいことはシンプルだったんですが、どうすれば型の補完が効くかを深掘りできていい経験になりました。

この記事が参考になったからコーヒーくらいおごってもいいぜという方は、以下からサポートいただけると次の記事書くモチベになりますのでよろしくお願いします

Buy Me A Coffee
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次