useFormStatusで、子のボタンから送信中かどうかを読む方法

​​React​TypeScript,useActionState,useFormStatus,フォーム

送信ボタンを1つの部品にして、React 19 の useFormStatus で送信中かどうかを読ませると、action に関数を渡したフォームなら、どこに置いても、送信中は自動で押せなくなります。フォームの側から props を渡す必要はありません。

筆者は検証環境で、使い回せる送信ボタンの部品を作り、置き場所やフォームの作り方を変えて動かしました。部品を何段下に置いても読めること、2つのフォームで使っても状態が混ざらないこと、押されたボタンの見分け方、そして pending が true にならない置き方をまとめます。<form> の action に関数を渡す書き方そのものや、useFormStatus が返す4つの値の基本は、このブログの記事「React 19で、formのactionに関数を渡して送信する方法」で扱っています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react / @types/react-dom 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。GIF は、同じコンポーネントを Playwright 1.56.1 の Chromium で動かして撮ったものです。サーバーは検証用に作った偽物で、こちらが合図するまで返事をしません。

どのフォームにも置ける送信ボタンを作る

useFormStatus は react-dom から読み込む Hook です。useFormStatus のリファレンスの、戻り値の説明によると、pending は、親の <form> が送信中なら true になります。data には送信中のフォームの FormData が入り、送信中でなければ null です。

この2つを使って、次の SubmitButton を作りました。親のフォームが送信中のあいだは押せなくなり、文字が pendingText に変わります。

tsx
import { useFormStatus } from 'react-dom';

type Props = {
  children: string;
  pendingText: string;
  name?: string;
  value?: string;
};

// どのフォームにも置ける送信ボタン。親の form の送信中は押せなくなる。
// name と value を渡したときは、押されたのが自分のときだけ pendingText を出す。
export function SubmitButton({ children, pendingText, name, value }: Props) {
  const { pending, data } = useFormStatus();
  const pressed = pending && (name === undefined || data.get(name) === value);
  return (
    <button type="submit" name={name} value={value} disabled={pending}>
      {pressed ? pendingText : children}
    </button>
  );
}

name と value は、1つのフォームに送信ボタンが2つ以上あるときのためのものです。使い方は次の節で確かめます。

TypeScript では、data.get を呼ぶ前に pending を確かめます。@types/react-dom 19.3.0 では、pending: false のときの data は null 型です。確かめずに data.get('title') と書くと、tsc は次のエラーを出しました。

text
typecheck/DataWithoutPending.tsx(6,21): error TS18047: 'data' is possibly 'null'.

上の SubmitButton では、pending && のあとで data.get を呼んでいるので、エラーになりません。

form から何段下に置いても読める

SubmitButton は、<form> の直接の子でなくてもかまいません。検証では、<form> の中に Card、その中に Footer を置き、Footer の中に SubmitButton を2つ並べました。どちらのボタンにも、フォームの props は渡していません。

tsx
import type { ReactNode } from 'react';
import { send } from './server';
import { SubmitButton } from './SubmitButton';

// SubmitButton を、form から2段下のコンポーネントに置く
function Card({ children }: { children: ReactNode }) {
  return <section className="card">{children}</section>;
}
function Footer() {
  return (
    <div className="footer">
      <SubmitButton name="intent" value="draft" pendingText="下書きを保存中…">
        下書き保存
      </SubmitButton>
      <SubmitButton name="intent" value="publish" pendingText="公開中…">
        公開
      </SubmitButton>
    </div>
  );
}

export function Post() {
  async function save(formData: FormData) {
    await send(`${formData.get('intent')}:${formData.get('title')}`);
  }

  return (
    <form action={save}>
      <Card>
        <input name="title" />
        <Footer />
      </Card>
    </form>
  );
}

入力欄に「猫の手」と入れて「公開」を押し、偽のサーバーが返事をする前とあとで画面を記録しました。続けて「肉球」と入れ、「下書き保存」でも同じことをしました。出力の {…} はフォームの中の入力欄とボタンで、(押せない) は disabled になっていることを示します。サーバー は偽のサーバーの記録です。

text
$ node run.mjs Post
  公開を押した直後: {title="猫の手" ボタン=["下書き保存"(押せない) "公開中…"(押せない)]} | サーバー=[受付:publish:猫の手]
  返事のあと: {title="" ボタン=["下書き保存" "公開"]} | サーバー=[受付:publish:猫の手 → 完了:publish:猫の手]
  下書き保存を押した直後: {title="肉球" ボタン=["下書きを保存中…"(押せない) "公開"(押せない)]} | サーバー=[受付:publish:猫の手 → 完了:publish:猫の手 → 受付:draft:肉球]
  返事のあと: {title="" ボタン=["下書き保存" "公開"]} | サーバー=[受付:publish:猫の手 → 完了:publish:猫の手 → 受付:draft:肉球 → 完了:draft:肉球]

2段下に置いた SubmitButton でも、送信中は pending が true になり、2つのボタンがどちらも押せなくなりました。返事が来ると、元に戻っています。

押されたボタンだけ文字を変える

「公開」を押したときは「公開中…」、「下書き保存」を押したときは「下書きを保存中…」と、押されたボタンだけ文字が変わりました。サーバーの記録にも publish と draft が届いています。押したボタンの name と value(ここでは intent と publish など)が、FormData に入っていたということです。

MDN の <button> の説明にも、ボタンの name は value と組になってフォームのデータとして送られる、とあります。<form> のリファレンスの、複数の送信方法を扱う例でも、action の関数が formData.get("button") で、押されたボタンの値を読んでいます。検証の結果のとおり、useFormStatus の data からも、押されたボタンの値を読めます。そのため、SubmitButton は data.get(name) === value で、押されたのが自分かどうかを見分けられます。

押されていない方のボタンも、送信中は押せなくしています。送信中にもう一方のボタンを押して、2回目の送信が始まるのを防ぐためです。

同じ部品を2つのフォームに置いても、混ざらない

useFormStatus が返すのは、その部品を囲んでいる <form> の状態だけです。useFormStatus のリファレンスの注意事項にも、親の <form> の状態だけを返すと書かれています。同じページに SubmitButton を使うフォームが2つあっても、片方の送信で、もう片方のボタンが止まることはないはずです。これを確かめました。

送信中に、送っている内容を表示する SendingNote も作りました。リファレンスの、送信中のデータを読む例と同じく、data から入力された値を読みます。

tsx
import { useFormStatus } from 'react-dom';

// 送信中だけ、送っている内容を出す。
export function SendingNote({ field }: { field: string }) {
  const { pending, data } = useFormStatus();
  if (!pending) return null;
  return <p className="note">「{String(data.get(field))}」を送信中…</p>;
}
tsx
import { send } from './server';
import { SendingNote } from './SendingNote';
import { SubmitButton } from './SubmitButton';

export function TwoForms() {
  async function comment(formData: FormData) {
    await send(`コメント:${formData.get('body')}`);
  }
  async function subscribe(formData: FormData) {
    await send(`登録:${formData.get('email')}`);
  }

  return (
    <>
      <form action={comment}>
        <input name="body" />
        <SubmitButton pendingText="送信中…">コメントする</SubmitButton>
        <SendingNote field="body" />
      </form>
      <form action={subscribe}>
        <input name="email" />
        <SubmitButton pendingText="登録中…">登録する</SubmitButton>
        <SendingNote field="email" />
      </form>
    </>
  );
}

コメントの欄に「にゃー」と入れて「コメントする」を押しました。

text
$ node run.mjs TwoForms
  送信前: {body="" ボタン=["コメントする"]} {email="" ボタン=["登録する"]} | サーバー=[]
  コメントするを押した直後: {body="にゃー" ボタン=["送信中…"(押せない)] 「にゃー」を送信中…} {email="" ボタン=["登録する"]} | サーバー=[受付:コメント:にゃー]
  返事のあと: {body="" ボタン=["コメントする"]} {email="" ボタン=["登録する"]} | サーバー=[受付:コメント:にゃー → 完了:コメント:にゃー]

送信中になったのは、コメントのフォームだけでした。送っている内容の表示も、コメントのフォームにだけ出ています。登録のフォームの「登録する」は、そのあいだも押せるままでした。

次の GIF は、Post と TwoForms を Chromium の画面に縦に並べ、3回ボタンを押した様子です。上の「① 2つのボタンを持つフォーム」が Post、下の「② 同じ SubmitButton を使う別の2つのフォーム」が TwoForms です。1回目は①の「公開」、2回目は②の「コメントする」、3回目は①の「下書き保存」を押しました。

画面の上部には「クリック 1回目/3回」のように何回目かが出て、その下の帯にはサーバーの状態が出ます。押した直後と、サーバーが返事をした直後は、変わったボタンや表示がピンクの枠と文字になります。

2つのボタンを持つフォームと、別の2つのフォームを縦に並べ、3回ボタンを押したときのボタンの文字と押せる状態の変化
GIFアニメーション:3回ボタンを押したときの動き。押したボタンだけが「公開中…」「送信中…」「下書きを保存中…」に変わり、同じフォームのボタンは押せなくなる。ほかのフォームのボタンは変わらない

送信中は、入力欄もまとめて止める

useFormStatus は、ボタン以外の部品でも呼べます。送信中に入力欄も書き換えられないようにしたいときは、<fieldset> で包む部品を作るのが手軽です。MDN の <fieldset> の説明によると、<fieldset> に disabled を付けると、中のフォームの部品がすべて無効になります。

tsx
import type { ReactNode } from 'react';
import { useFormStatus } from 'react-dom';

// 送信中は、中の入力欄をまとめて押せなくする。
export function PendingFieldset({ children }: { children: ReactNode }) {
  const { pending } = useFormStatus();
  return <fieldset disabled={pending}>{children}</fieldset>;
}
tsx
import { send } from './server';
import { PendingFieldset } from './PendingFieldset';
import { SubmitButton } from './SubmitButton';

export function Fieldset() {
  async function save(formData: FormData) {
    await send(`${formData.get('name')}/${formData.get('color')}`);
  }

  return (
    <form action={save}>
      <PendingFieldset>
        <input name="name" />
        <input name="color" />
        <SubmitButton pendingText="保存中…">保存</SubmitButton>
      </PendingFieldset>
    </form>
  );
}

「たま」と「三毛」を入れて「保存」を押しました。

text
$ node run.mjs Fieldset
  保存を押した直後: {name="たま"(押せない) color="三毛"(押せない) ボタン=["保存中…"(押せない)]} | サーバー=[受付:たま/三毛]
  返事のあと: {name="" color="" ボタン=["保存"]} | サーバー=[受付:たま/三毛 → 完了:たま/三毛]

送信中は、2つの入力欄とボタンがすべて押せなくなりました。同じ MDN の説明には、無効になった部品はフォームと一緒に送信されない、ともあります。この検証では、サーバーに「たま/三毛」が届いていて、送った値は欠けませんでした。

useActionState のフォームでも、そのまま使える

useActionState が返す関数を <form> の action に渡すこともできます(useActionState のリファレンスの、form と使う説明)。このフォームに SubmitButton を置いても、送信中を読めるかを確かめました。

tsx
import { useActionState } from 'react';
import { send } from './server';
import { SubmitButton } from './SubmitButton';

export function WithActionState() {
  const [count, formAction] = useActionState(async (prev: number, formData: FormData) => {
    await send(`${formData.get('title')}`);
    return prev + 1;
  }, 0);

  return (
    <form action={formAction}>
      <input name="title" />
      <SubmitButton pendingText="保存中…">保存</SubmitButton>
      <p className="count">保存した回数: {count}</p>
    </form>
  );
}
text
$ node run.mjs WithActionState
  保存を押した直後: {title="猫の手" ボタン=["保存中…"(押せない)] 保存した回数: 0} | サーバー=[受付:猫の手]
  返事のあと: {title="" ボタン=["保存"] 保存した回数: 1} | サーバー=[受付:猫の手 → 完了:猫の手]

送信中は「保存中…」になり、返事のあとに回数が1に増えました。useActionState にも送信中を表す isPending がありますが、それを props で SubmitButton に渡さなくても、ボタンは自分で送信中を読めます。

pending が true にならない置き方

useFormStatus を呼んでも、pending が true にならない場合が2つありました。

1つ目は、action ではなく onSubmit で送信を扱うフォームです。

tsx
import type { FormEvent } from 'react';
import { send } from './server';
import { SubmitButton } from './SubmitButton';

// action ではなく onSubmit で送信を扱う
export function OnSubmit() {
  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await send(String(formData.get('title')));
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <SubmitButton pendingText="保存中…">保存</SubmitButton>
    </form>
  );
}
text
$ node run.mjs OnSubmit
  保存を押した直後: {title="猫の手" ボタン=["保存"]} | サーバー=[受付:猫の手]
  返事のあと: {title="猫の手" ボタン=["保存"]} | サーバー=[受付:猫の手 → 完了:猫の手]

サーバーが受け付けて返事を待っているあいだも、ボタンは「保存」のままで、押せる状態でした。<form> のリファレンスの、onSubmit で送信を扱う説明には、onSubmit と比べる補足があります。そこには、関数を action に渡したときは送信が Transition の中で動き、React が送信中の状態を管理する、と書かれています。onSubmit で送る書き方は、これに当たりません。検証でも、useFormStatus の pending は false のままでした。

2つ目は、ボタンを <form> の外に置き、HTML の form 属性でフォームとつないだ場合です。

tsx
import { useFormStatus } from 'react-dom';
import { send } from './server';

function OutsideSaveButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" form="edit-form" disabled={pending}>
      {pending ? '保存中…' : '保存'}
    </button>
  );
}

// ボタンを form の外に置き、HTML の form 属性でフォームとつなぐ
export function OutsideButton() {
  async function save(formData: FormData) {
    await send(String(formData.get('title')));
  }

  return (
    <div className="dialog">
      <form id="edit-form" action={save}>
        <input name="title" />
      </form>
      <div className="dialog-footer">
        <OutsideSaveButton />
      </div>
    </div>
  );
}
text
$ node run.mjs OutsideButton
  保存を押した直後: {title="猫の手" ボタン=[]} {ボタン=["保存"]} | サーバー=[受付:猫の手]
  返事のあと: {title="" ボタン=[]} {ボタン=["保存"]} | サーバー=[受付:猫の手 → 完了:猫の手]

1つ目の {…} がフォーム、2つ目がフォームの外のボタンです。ボタンを押すとフォームは送信され、save も呼ばれて、返事のあとに入力欄は空になりました。それでも、ボタンは送信中に「保存中…」にならず、押せるままでした。MDN の <button> の説明にあるとおり、form 属性はボタンをフォームに結びつけます。しかし、OutsideSaveButton は <form> の中に置かれていません。useFormStatus のリファレンスの、pending が true にならないときの説明によると、<form> の中に置かれていないコンポーネントで呼ぶと、pending は常に false です。

注意

送信ボタンを部品にしても、onSubmit で送るフォームや、form 属性で外からつないだボタンでは、送信中に押せなくなりません。どちらも検証ではエラーが出なかったので、部品を使い回す前に、フォームの書き方と置き場所を確かめておきます。

<form> を返すコンポーネントそのものの中で useFormStatus を呼んだ場合も、pending は true になりません(リファレンスの注意事項)。この場合の動きは、記事「React 19で、formのactionに関数を渡して送信する方法」で確かめています。

ポイント

useFormStatus は、<form action={関数}> の中に置いた部品で呼びます。部品を何段下に置いても読め、同じ部品を別のフォームで使っても状態は混ざりません。押されたボタンは、data の FormData に入るボタンの name と value で見分けられます。

​Posted by 86