Reactでformの中のbuttonを押すと送信される理由

​​React​ESLint,HTML,TypeScript,フォーム

<form> の中に置いた <button> に type を書かないと、HTML の決まりでは送信ボタンとして扱われます。React は type を補わずにそのまま HTML に出すので、押すとフォームが送信され、送信先を決めていないフォームは今のページを読み込み直します。ログインフォームの「パスワードを表示」ボタンを押しただけで、ページが読み込み直されて入力が消えるときは、この仕組みが原因です。送信しないボタンに type="button" を書けば、送信されなくなります。

私は検証環境で、type の書き方を変えた「表示」ボタンを作り、何が起きるかを Chromium で確かめました。直し方とあわせて、type を書いたつもりでも送信ボタンのままになる書き方と、送信したボタンを突き止める方法も紹介します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。react / react-dom 19.3.1、@types/react 19.3.0、TypeScript 6.0.3、esbuild 0.28.2、ESLint 9.39.5、eslint-plugin-react 7.37.5、typescript-eslint 8.72.2 を使いました。.tsx を esbuild で開発用にまとめ、Playwright 1.63.0 で動かした Chromium 153 で操作しました。StrictMode、Vite などのビルドツールや Next.js などのフレームワーク、Chromium 以外のブラウザーでは試していません。

上下に2つのフォームが並んでいる。上の「type なし」の欄に neko と入れて表示ボタンを押すと、読み込んだ回数が1回から2回に増え、URLが /Demo?password=neko に変わって欄が空になる。次に下の「type="button"」の欄に neko と入れて表示ボタンを押すと、回数は2回のまま、欄の文字が伏せ字から neko に変わる
上は表示ボタンでページが読み込み直されて入力が消え、下はパスワードが見えるようになるだけ

type を書かない button は、HTML では送信ボタン

HTML の仕様にある、button 要素の type 属性の説明では、type には submit(送信する)、reset(入力を初期値に戻す)、button(何もしない)の3つの値があります。type を書かなかったときと、決められた3つ以外の値を書いたときは、どちらも Auto という状態になります。Auto の button は、command 属性・commandfor 属性がなく、select 要素の直下でもなければ、送信ボタンとして扱われます。

MDN の button 要素の type 属性の説明でも、フォームに関連付けられたボタンで type がないときや、空や不正な値のときは submit になると書かれています。

これは、ブラウザーで要素を調べると確かめられます。次の NoType の「表示」ボタンについて、getAttribute('type') と type プロパティを読みました。

tsx
import { useState } from 'react';

export function NoType() {
  const [show, setShow] = useState(false);
  return (
    <form>
      <input name="email" aria-label="メールアドレス" />
      <input name="password" type={show ? 'text' : 'password'} aria-label="パスワード" />
      <button onClick={() => setShow(!show)}>表示</button>
    </form>
  );
}

type 属性は null(書かれていない)でしたが、type プロパティは "submit" を返しました。仕様の type プロパティの説明にも、送信ボタンなら "submit" を返すとあります。type 属性を書いていなくても、type プロパティを読めば、そのボタンが送信ボタンかどうかが分かります。

React は type を補わない

React が出す HTML にも、type は付きません。react-dom/server の renderToStaticMarkup で、type を書いた場合と書かない場合を文字列にしました。

tsx
import { renderToStaticMarkup } from 'react-dom/server';
console.log(renderToStaticMarkup(<button>表示</button>));
console.log(renderToStaticMarkup(<button type="button">表示</button>));
text
<button>表示</button>
<button type="button">表示</button>

ブラウザーで描画した NoType のボタンも、outerHTML は <button>表示</button> でした。TypeScript の型(@types/react)でも、button の type は "submit" | "reset" | "button" | undefined で、書かなくてもエラーになりません。React の書き方が原因というより、HTML の button をそのまま使っているために起きることです。

送信がページの再読み込みになるまで

仕様の button 要素の説明では、押されたボタンがフォームに属していて、かつ送信ボタンなら、そのフォームを送信すると決めています。フォーム送信の手順では、送信先(action)が空なら、今のページの URL を送信先にします。MDN の form 要素の method 属性の説明によると、method の既定は GET で、フォームのデータは URL の ? のあとに付きます。

NoType で、メールアドレスとパスワードを入れて「表示」を押した結果です。

text
押す前: URL=/NoType / 読み込んだ回数:1 / パスワード欄 type=password 値=「nekoneko」
「表示」を押したあと: URL=/NoType?email=tama%40example.com&password=nekoneko / 読み込んだ回数:2 / パスワード欄 type=password 値=「」

ページを読み込んだ回数が増え、パスワード欄は空になりました。show の state も初めの false に戻ったので、パスワード欄は伏せ字のままです。

注意

この結果のとおり、type を書き忘れた「表示」ボタンを押しただけで、入力したパスワードがページの URL に入りました。

type を書いたつもりでも送信ボタンのままになる書き方

値を書きまちがえた

type="buton" のように値をまちがえると、仕様でいう不正な値にあたり、書かなかったときと同じく送信ボタンになります。検証でも、type プロパティは "submit" を返し、押すと URL が /Typo?password=nekoneko になって読み込み直されました。

ただし、tsc で型チェックをすれば、ビルドの前に気づけます。

text
typo/typo.tsx(9,15): error TS2820: Type '"buton"' is not assignable to type '"button" | "reset" | "submit" | undefined'. Did you mean '"button"'?

esbuild は型を調べずに変換するので、この状態でもビルドは通りました。型チェックを別に走らせていないと、見逃します。

自作の Button コンポーネントが type を受け取らない

見た目をそろえるために、button を包んだコンポーネントを作ることがあります。

tsx
import type { ComponentProps } from 'react';

function Button(props: ComponentProps<'button'>) {
  return <button className="btn" {...props} />;
}

// 使う側
<Button onClick={() => setShow(!show)}>表示</Button>

使う側が type を渡さなければ、中の button にも type は付きません。検証では NoType と同じく送信ボタンになり、押すと URL が /WrapperNoType?password=nekoneko になって読み込み直されました。

form の外に置いて、form 属性で結びつけた

<form> の外にある button は、フォームに属さないので、押しても何も送信しません。検証でも、外に置いたボタンは押しても URL は変わらず、入力も残りました。

ところが、MDN の button 要素の form 属性の説明にあるとおり、form="search" のようにフォームの id を書くと、外にあってもそのフォームに属します。type がなければ送信ボタンなので、押すと URL が /Outside?q=たま になって読み込み直されました。

送信しないボタンには type="button" を書く

直し方は、送信しないボタンに type="button" を書くことです。

tsx
<button type="button" onClick={() => setShow(!show)}>表示</button>

検証では、押しても URL と読み込んだ回数は変わらず、パスワード欄が type=text になって「nekoneko」が見えるようになりました。type プロパティも "button" を返しました。

自作の Button コンポーネントは、type を渡さなければ "button" になるようにしておくと、書き忘れても送信されません。

tsx
import type { ComponentProps } from 'react';

// type を渡さなければ "button" になる Button コンポーネント("submit" のときだけ送信ボタン)
function SafeButton({ type, ...props }: ComponentProps<'button'>) {
  return <button className="btn" type={type === 'submit' ? 'submit' : 'button'} {...props} />;
}

// 使う側
<SafeButton onClick={() => setShow(!show)}>表示</SafeButton>
<SafeButton type="submit">ログイン</SafeButton>

この形では、type="reset" は使えません。リセットボタンが要らないフォームのための書き方です。検証では、「表示」は押してもパスワードが見えるようになるだけで、onSubmit は呼ばれませんでした。「ログイン」を押したときだけ、onSubmit(preventDefault を呼んでメッセージを出すもの)が動きました。

なお、type を書かずに、onClick の中で e.preventDefault() を呼ぶ形でも、検証では送信されませんでした。ただ、この形ではボタンの type プロパティは "submit" のままで、後の節で紹介する ESLint のルールでもエラーになりました。

どのボタンが送信したかを調べる

フォームにボタンがいくつもあり、どれが送信を起こしているのか分からないときは、onSubmit で受け取ったイベントの nativeEvent.submitter を見ます。MDN の SubmitEvent.submitter の説明のとおり、送信を起こしたボタンが入っています。

tsx
import type { SubmitEvent } from 'react';

function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
  e.preventDefault();
  const submitter = e.nativeEvent.submitter;
  console.log('送信したボタン:', submitter?.textContent, '/ e.submitter:', e.submitter?.textContent);
  setMessage('ログインします');
}

「表示」と「ログイン」の2つのボタンを type なしで置き、「表示」を押すと、コンソールに次のように出て、「ログインします」とも表示されました。

text
送信したボタン: 表示 / e.submitter: 表示

type を書き忘れた「表示」ボタンが、ログインの処理まで動かしていたことが分かります。検証環境(react-dom 19.3.1、@types/react 19.3.0)では、nativeEvent を通さず e.submitter と書いても、同じボタンを取れました。

ESLint の react/button-has-type で書き忘れを見つける

eslint-plugin-react には、type のない button を見つける react/button-has-type というルールがあります。

js
// eslint.config.js
import react from 'eslint-plugin-react';
import tseslint from 'typescript-eslint';

export default [
  {
    files: ['src/**/*.tsx'],
    languageOptions: { parser: tseslint.parser },
    plugins: { react },
    rules: { 'react/button-has-type': 'error' },
  },
];

この記事の例をまとめたファイルに使うと、type のない button の行が、次のようにエラーになりました(作業フォルダのパスは /path/to/app に置き換えています)。

text
/path/to/app/src/cases.tsx
   11:7   error  Missing an explicit type attribute for button  react/button-has-type
   44:7   error  Missing an explicit type attribute for button  react/button-has-type
   45:7   error  Missing an explicit type attribute for button  react/button-has-type
   53:10  error  Missing an explicit type attribute for button  react/button-has-type
   92:7   error  Missing an explicit type attribute for button  react/button-has-type
   93:7   error  Missing an explicit type attribute for button  react/button-has-type
  105:7   error  Missing an explicit type attribute for button  react/button-has-type

11行目は NoType、44・45行目は送信したボタンを調べる例の「表示」と「ログイン」、92・93行目はフォームの外に置いたボタン、105行目は onClick で preventDefault を呼ぶ例です。53行目は、Button コンポーネントの中の <button className="btn" {...props} /> です。一方で、<Button onClick={...}>表示</Button> と使っている側の行はエラーになりませんでした。このルールが調べるのは button 要素そのものなので、自作のコンポーネントは、中の button で type を決めておく必要があります。SafeButton の type={type === 'submit' ? 'submit' : 'button'} は、エラーになりませんでした。

​Posted by 86