🫧

Dialog内でform要素を使用するとsubmitがバブリングする!?

2024/07/18に公開2

起きた事象

MUIのDialogコンポーネントを使用して、下記のような構造の入力フォームを作成していました。
※実際には他のコンポーネントが間に挟まっていますが簡略化のために省略

<form
  onSubmit={(e) => {
    e.preventDefault();
    console.log("outer");
  }}
>
  <button>outer button</button>
  <Dialog>
    <form
      onSubmit={(e) => {
        e.preventDefault();
        console.log("inner");
      }}
    >
      <button>inner button</button>
    </form>
  </Dialog>
</form>

上記のそれぞれの役割を軽く書いておくと、外側のform要素は入力フォーム全体に対応しており、Dialogはその一部の入力支援を行う検索ダイアログに対応しています。

例としては、会員登録フォームと、住所の入力支援を行うダイアログのようなものをイメージしていただければと思います。

通常のhtmlではform要素をネストして書くことができませんが、DialogコンポーネントはDOMツリー上でbody要素配下にレンダリングを行います。

そのため、上記のコードを実際にブラウザから確認してみると、下記のように親子関係にはなっていません。
DOMツリー

さて、このような状態でDialog内のsubmitボタンを押してみると、外側のform要素のsubmitイベントまで発火しています。

イベントの実行順序は下記の通りで、イベントのバブリングが起きていそうです。

  1. 内側のform要素
  2. 外側のform要素

この動作は、DOM上でのform要素の位置関係だけをみると不思議に思えます。

なぜ親子関係にない要素のイベントがバブリングするのか

結論として、この事象はバグというわけでもなく、Dialogコンポーネントが内部で使用しているreact-domのcreatePortalという機能によるものでした。

createPortalは、DOM上でレンダリングする位置を任意の場所へ変更できる機能を提供しており、MUIのDialog以外にもshadcn/ui(つまりRadix UI)などが提供しているDialogは、この機能を使用してbody要素配下にレンダリングしています。

上記のリファレンスを確認すると、しっかりとイベント伝搬の注意が記載されています。

ポータルからのイベントは、DOM ツリーではなく React ツリーに沿って伝播します。例えば、ポータル内部でクリックが起き、ポータルが <div onClick> でラップされている場合、その onClick ハンドラが発火します。これが問題を引き起こす場合、ポータル内部からイベント伝播を停止するか、ポータル自体を React ツリー内で上に移動します。

つまり、createPortalを使用する場合、イベントの伝搬はDOM上の位置関係ではなく、Reactツリー上での位置関係によって決まるということですね。

間違った解釈

ややこしいのですが、下記のようにDialogの子要素(すなわちcreatePortalによってレンダリング場所をずらす要素)にform要素を含めない場合は、外側のform要素のsubmitイベントは発火されません。

  <form
    onSubmit={(e) => {
      e.preventDefault();
      console.log("outer");
    }}
  >
    <button>outer button</button>
    <Dialog>
-     <form
-       onSubmit={(e) => {
-         e.preventDefault();
-         console.log("inner");
-       }}
-     >
        <button>inner button</button>
-     </form>
    </Dialog>
  </form>

当初同じような理屈で、外側のform要素のsubmitイベントが発火するだろうと思っていたのですが発火せず、調べていたら同じ疑問を持った人がIssueを挙げてくれていました。

これらの考え方として、上記のIssueではcreatePortalによるイベントの伝搬と、submitイベント自体の発火は別物として考える必要があると回答されています。

Dialog内にform要素が存在する場合の解釈

まず、Dialog内のボタンは親要素として存在するform要素に「関連付けされる」ことになります。

当然このボタンを押下した場合、内側のform要素のsubmitイベントがトリガーされます。(ここまでは通常のHTMLの世界のお話)

その後、Reactツリー上で親要素のformにイベントがバブリングして、外側のform要素のsubmitイベントが発火することになります。(ここはcreatePortal機能の仕様)

Dialog内にform要素が存在しない場合の解釈

Dialog内のボタン要素は関連付けされるform要素を探しますが、DOMツリー上で親のform要素は存在しないため、「関連付けされない」ことになります。

このボタンを押下した場合、関連付けが行われていないため、submitイベント自体がトリガーされません。

その結果、外側のform要素のsubmitイベントは発火しないことになります。

つまるところ

submitイベントは、トリガーとなるボタンがform要素に「関連付けされている」場合に発火するため、createPortalによるバブリングの話とは別物だよ、ということですね。

関連付けされるか否かは、idform属性による指定を省いた場合、DOM上の親子関係を参照して決定するようなので、Dialog内にform要素が存在しないと関連付けされないことになります。

逆に当たり前ですが、idformを指定した下記のコードは、Dialog外のform要素と「関連付けされている」ため、submitイベントが発火します。

  <form
+   id="form"
    onSubmit={(e) => {
      e.preventDefault();
      console.log("outer");
    }}
  >
    <button>outer button</button>
    <Dialog>
-     <form
-       onSubmit={(e) => {
-         e.preventDefault();
-         console.log("inner");
-       }}
-     >
+       <button form="form">inner button</button>
-     </form>
    </Dialog>
  </form>

form要素がネストする場合の対応

冒頭にも記載した通り、DOMツリー上でform要素をネストすることは許されていません。

Reactツリー上でネストすることは、エラーになるというわけではありませんが、DOMツリーとの乖離が生じるため注意が必要となます。

内側のform要素のイベントのみを発火させたい場合、下記の2通りの対応が考えられます。

  • form要素のネストを残したままイベントの伝搬を防ぐ
  • form要素のネストを解消して同等の処理を実現する

form要素のネストを残したままイベントの伝搬を防ぐ

外側のform要素で発火するイベントは、結局のところバブリングによるものであることは変わりないので、内側form要素のイベントハンドラ内でstopPropagation()を実行すれば、外側のformは反応しません。

  <form
    onSubmit={(e) => {
      e.preventDefault();
      console.log("outer");
    }}
  >
    <button>outer button</button>
    <Dialog>
      <form
        onSubmit={(e) => {
          e.preventDefault();
+         e.stopPropagation();
          console.log("inner");
        }}
      >
        <button form="form">inner button</button>
      </form>
    </Dialog>
  </form>

form要素のネストを解消して同等の処理を実現する

今回の場合、入力支援用の検索ダイアログ用のform要素は、検索用のAPIに対してどのような検索ワードを渡すかの用途で使用していました。

つまり、入力値を取得してAPIを叩くことだけに着目すれば、form要素におけるsubmitイベントの引数で取得せずとも、useRefや使用しているならReact Hook FormのgetValues等を使用して入力値を取得することができます。

ただし、UXやアクセシビリティの観点で、form要素を使用した場合が適当となる場合もあるため、用途やユーザーを考慮する必要があります。

まとめ

  • Dialogに使用されるcreatePortal機能を利用する場合、イベントのバブリングはDOMツリー上での位置ではなく、Reactツリー上での位置に依存する
  • submitイベントの発火条件は、トリガーとなるボタンがform要素に関連付いているかどうかによる
  • submitイベントの伝搬を防ぐ場合には、stopPropagation()を実行するか、form要素のネストを解消する
GitHubで編集を提案

Discussion

nap5nap5

form要素のネストを残したままイベントの伝搬を防ぐ

こちらのパターンでRHFを使ってチャレンジしてみました

構造化の視点でもstopPropagation使っていったほうがハンディな気はしました

ナナセナナセ

デモコードありがとうございます!

おっしゃる通り、下手にReactの"魔法"に頼った実装をするよりも、ネイティブなHTMLに則ってformを使用したセマンティックな構成にした方が綺麗な気がしますね!

加えて複数人で開発している場合は、コードを読んだ全員がその意図に気づくのも困難だと思うので、なぜstopPropagationを実行しているのかコメントに残しておくのが親切かもしれませんね。