Reactでinputの値を取得する方法は、入力のたびに state へ写す「制御コンポーネント(useState+onChange)」と、必要なときに DOM から読む「非制御コンポーネント(useRef・FormData・form action)」の2系統です。 入力中の値を画面に反映したいなら前者、送信時にまとめて読めれば足りるなら後者が向いています。

この記事では、同じフォームを4通り(useState+onChange/useRef/FormData/React 19 の form action)で書いたコードを並べ、送信時の出力を載せます。あわせて、text・number・checkbox・radio・select・textarea・file ごとの「読むプロパティとイベントの型」の早見表、「A component is changing an uncontrolled input to be controlled」警告の再現と直し方、number 入力が文字列で返る挙動への対処をまとめました。掲載した出力は、React 19.3.0、TypeScript 7.0.2(@types/react 19.3.0)で書いたコードを、Playwright 1.56.0 から Chromium 141 で動かして得たものです。

入力の種類ごとの早見表

入力の種類 読むプロパティ 得られる型 制御するときの属性 非制御の初期値 onChange のイベントの型
type="text" e.target.value string value defaultValue ChangeEvent<HTMLInputElement>
type="number" e.target.value(数値は valueAsNumber) string(valueAsNumber は number) value defaultValue ChangeEvent<HTMLInputElement>
type="checkbox" e.target.checked boolean checked defaultChecked ChangeEvent<HTMLInputElement>
type="radio" e.target.value(選ばれたものの value) string checked={state === 値} defaultChecked ChangeEvent<HTMLInputElement>
select e.target.value string value defaultValue ChangeEvent<HTMLSelectElement>
select(multiple) e.target.selectedOptions 配列に変換して string の配列 value(配列) defaultValue(配列) ChangeEvent<HTMLSelectElement>
textarea e.target.value string value defaultValue ChangeEvent<HTMLTextAreaElement>
type="file" e.target.files FileList(1件目は files?.[0]) 制御できない(読み取り専用) 指定できない ChangeEvent<HTMLInputElement>
form(送信) new FormData(e.currentTarget) FormData ― ― SubmitEvent<HTMLFormElement>

ほとんどの入力は value を読み、checkbox だけは checked、file だけは files を読む、と覚えると整理しやすくなります。送信イベントの型は、@types/react 19.3.0 では SubmitEvent です(詳しくは後半の「イベントの型」で扱います)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Reactフォームinputの値取得|onChangeとuseRefの使い分け」

制御コンポーネントと非制御コンポーネントの違い

観点 制御コンポーネント 非制御コンポーネント
値の持ち主 React の state ブラウザの DOM
書き方 value と onChange をセットで書く defaultValue を書き、必要なときに読む
値を読むタイミング いつでも(state を見るだけ) 送信時など、読みに行ったとき
入力のたびの再レンダリング 起こる 起こらない
入力中の検証・文字数表示・整形 書きやすい 別途イベントを付ける必要がある
外から値を書き換える state を更新するだけ ref 経由で DOM を操作する
向いている場面 入力に応じて画面が変わるフォーム 送信時にまとめて読めれば足りるフォーム
flowchart LR
  subgraph 制御
    A1["入力"] --> A2["onChange"] --> A3["setState"] --> A4["再レンダリング"] --> A5["value に反映"]
  end
  subgraph 非制御
    B1["入力"] --> B2["DOM が値を保持"]
    B3["送信"] --> B4["ref や FormData で読む"]
  end

value を書いたら制御、defaultValue を書いたら非制御です。1つの入力欄で両方を混ぜないことが、後述の警告を避ける一番のポイントになります。

同じフォームを4通りで書く

題材は、備品の申請フォームです。件名(text)、数量(number)、種類(select)、急ぎ(checkbox)の4項目を入力して送信します。操作はすべて同じで、件名に「ノートPC」、数量に 3、種類に「机」、急ぎにチェックを入れて送信しました。

① useState+onChange(制御コンポーネント)

import { useState, type ChangeEvent, type SubmitEvent } from "react";

type Category = "pc" | "desk" | "other";

export function ControlledForm() {
  const [form, setForm] = useState({ title: "", quantity: "1", category: "pc" as Category, urgent: false });

  // name 属性をキーにして、1つのハンドラで全項目を更新する
  const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
    const { name, value } = e.target;
    const next = e.target instanceof HTMLInputElement && e.target.type === "checkbox" ? e.target.checked : value;
    setForm((prev) => ({ ...prev, [name]: next }));
  };

  const handleSubmit = (e: SubmitEvent<HTMLFormElement>) => {
    e.preventDefault(); // ページの再読み込みを止める
    console.log("controlled:", JSON.stringify({ ...form, quantity: Number(form.quantity) }));
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" value={form.title} onChange={handleChange} />
      <input name="quantity" type="number" value={form.quantity} onChange={handleChange} />
      <select name="category" value={form.category} onChange={handleChange}>
        <option value="pc">パソコン</option>
        <option value="desk">机</option>
        <option value="other">その他</option>
      </select>
      <input name="urgent" type="checkbox" checked={form.urgent} onChange={handleChange} />
      <button type="submit">申請</button>
    </form>
  );
}
controlled: {"title":"ノートPC","quantity":3,"category":"desk","urgent":true}

state を1つのオブジェクトにまとめ、name 属性をキーにした共通の handleChange で更新しています。checkbox だけは value ではなく checked を読む必要があるので、type を見て分けています。数量は state では文字列のまま持ち、送信時に Number() で数値に変換しました(理由は「number 入力」の章で説明します)。

② useRef(非制御コンポーネント)

import { useRef, type SubmitEvent } from "react";

export function RefForm() {
  const titleRef = useRef<HTMLInputElement>(null);
  const quantityRef = useRef<HTMLInputElement>(null);
  const categoryRef = useRef<HTMLSelectElement>(null);
  const urgentRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: SubmitEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("ref:", JSON.stringify({
      title: titleRef.current?.value ?? "",
      quantity: quantityRef.current?.valueAsNumber ?? 0,
      category: categoryRef.current?.value ?? "",
      urgent: urgentRef.current?.checked ?? false,
    }));
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" ref={titleRef} defaultValue="" />
      <input name="quantity" type="number" ref={quantityRef} defaultValue={1} />
      <select name="category" ref={categoryRef} defaultValue="pc">
        <option value="pc">パソコン</option>
        <option value="desk">机</option>
        <option value="other">その他</option>
      </select>
      <input name="urgent" type="checkbox" ref={urgentRef} defaultChecked={false} />
      <button type="submit">申請</button>
    </form>
  );
}
ref: {"title":"ノートPC","quantity":3,"category":"desk","urgent":true}

useRef<HTMLInputElement>(null) で作った ref を入力欄に渡し、送信時に ref.current から読みます。入力中は再レンダリングが起こらず、初期値は defaultValue・defaultChecked で指定します。項目ごとに ref が必要になるので、項目が多いフォームでは次の FormData のほうが短く書けます。

③ FormData(非制御・ref も不要)

import type { SubmitEvent } from "react";

export function FormDataForm() {
  const handleSubmit = (e: SubmitEvent<HTMLFormElement>) => {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    console.log("formdata raw:", JSON.stringify(Object.fromEntries(fd)));
    console.log("formdata:", JSON.stringify({
      title: String(fd.get("title") ?? ""),
      quantity: Number(fd.get("quantity")),
      category: String(fd.get("category")),
      urgent: fd.get("urgent") === "on",
    }));
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <input name="quantity" type="number" defaultValue={1} />
      <select name="category" defaultValue="pc">
        <option value="pc">パソコン</option>
        <option value="desk">机</option>
        <option value="other">その他</option>
      </select>
      <input name="urgent" type="checkbox" />
      <button type="submit">申請</button>
    </form>
  );
}
formdata raw: {"title":"ノートPC","quantity":"3","category":"desk","urgent":"on"}
formdata: {"title":"ノートPC","quantity":3,"category":"desk","urgent":true}

new FormData(e.currentTarget) は、フォーム内の name 属性が付いた入力をまとめて読み取るブラウザ標準の機能です。1行目の出力のとおり、FormData から取り出した値はすべて文字列で、数量は "3"、チェックボックスは "on" になります。2行目では型をそろえるために変換しています。

チェックを外し、数量を空にして送信すると、次の出力になりました。

formdata raw: {"title":"机","quantity":"","category":"pc"}
formdata: {"title":"机","quantity":0,"category":"pc","urgent":false}

チェックを外した checkbox は、キーそのものが FormData に含まれません。 そのため fd.get("urgent") === "on" や fd.has("urgent") で判定します。また、空の数量は Number("") で 0 になる点にも気をつけます。

④ React 19 の form action

export function ActionForm() {
  function submit(fd: FormData) {
    console.log("action:", JSON.stringify({
      title: String(fd.get("title") ?? ""),
      quantity: Number(fd.get("quantity")),
      category: String(fd.get("category")),
      urgent: fd.get("urgent") === "on",
    }));
  }

  return (
    <form action={submit}>
      <input name="title" />
      <input name="quantity" type="number" defaultValue={1} />
      <select name="category" defaultValue="pc">
        <option value="pc">パソコン</option>
        <option value="desk">机</option>
        <option value="other">その他</option>
      </select>
      <input name="urgent" type="checkbox" />
      <button type="submit">申請</button>
    </form>
  );
}
action: {"title":"ノートPC","quantity":3,"category":"desk","urgent":true}

React 19 では、form の action に関数を渡せます。関数には FormData が引数として渡され、preventDefault を書く必要もありません。 react.dev の form のリファレンス(今回開いて確認)にも、action に関数を渡すと FormData が1つの引数として渡されること、成功後に非制御の入力がリセットされることが書かれています。

送信後の入力欄を比べる

4通りで違いが出るのが、送信後の入力欄の状態です。送信直後に各入力欄の値を読み取りました。

書き方 送信後の入力欄
① useState+onChange 件名「ノートPC」、数量 3、種類「机」、急ぎ ON のまま
② useRef 件名「ノートPC」、数量 3、種類「机」、急ぎ ON のまま
③ FormData 件名「ノートPC」、数量 3、種類「机」、急ぎ ON のまま
④ form action 件名は空、数量 1、種類「パソコン」、急ぎ OFF(初期値に戻る)

form action は、関数が完了すると非制御の入力欄を初期値(defaultValue)に戻します。 ①〜③で入力欄を空にしたいときは、①なら state を初期値に戻し、②③なら e.currentTarget.reset() を呼びます。

4通りの使い分け

書き方 使えるバージョン 入力中の値 コード量 向いている場面
① useState+onChange すべて いつでも使える 多め 入力中の検証、文字数表示、入力に応じた表示切り替え
② useRef すべて 読みに行けば取れる 項目ごとに ref 項目が1〜2個、フォーカス操作も一緒に行いたい
③ FormData すべて 送信時にまとめて 少ない 項目が多く、送信時に読めれば足りる
④ form action React 19 以降 送信時にまとめて 最も少ない React 19 で、送信中の表示や結果表示まで組みたい

入力中の値を画面に使うなら①、送信時だけなら③か④が基本です。項目数が多く検証ルールも複雑なフォームでは、React Hook Form などのフォーム用ライブラリを検討する段階になります。

React 18.3.1 で action に関数を渡して描画すると、次の警告が出ました(jsdom で確認。送信したときの動作は未実行です)。

Warning: Invalid value for prop `action` on <form> tag. Either remove it from the element, or pass a string or number value to keep it in the DOM.

入力の種類ごとに値を取り出す

早見表の内容を、実際のコードと出力で確かめます。値と型を表示する関数を用意し、各入力の onChange で呼びました。

import { useState, type ChangeEvent } from "react";

const show = (label: string, v: unknown) => console.log(`${label}: ${JSON.stringify(v)} (${typeof v})`);

export function AllInputs() {
  const [text, setText] = useState("");
  const [qty, setQty] = useState("");
  const [agree, setAgree] = useState(false);
  const [plan, setPlan] = useState("free");
  const [color, setColor] = useState("red");
  const [tags, setTags] = useState<string[]>([]);
  const [note, setNote] = useState("");
  const [fileName, setFileName] = useState("");

  return (
    <form>
      <input id="text" type="text" value={text}
        onChange={(e: ChangeEvent<HTMLInputElement>) => { setText(e.target.value); show("text value", e.target.value); }} />

      <input id="qty" type="number" value={qty}
        onChange={(e: ChangeEvent<HTMLInputElement>) => {
          setQty(e.target.value);
          show("number value", e.target.value);
        }} />

      <input id="agree" type="checkbox" checked={agree}
        onChange={(e: ChangeEvent<HTMLInputElement>) => {
          setAgree(e.target.checked);
          show("checkbox checked", e.target.checked);
          show("checkbox value", e.target.value);
        }} />

      {["free", "pro"].map((p) => (
        <input key={p} id={`plan-${p}`} type="radio" name="plan" value={p} checked={plan === p}
          onChange={(e: ChangeEvent<HTMLInputElement>) => { setPlan(e.target.value); show("radio value", e.target.value); }} />
      ))}

      <select id="color" value={color}
        onChange={(e: ChangeEvent<HTMLSelectElement>) => { setColor(e.target.value); show("select value", e.target.value); }}>
        <option value="red">赤</option>
        <option value="blue">青</option>
      </select>

      <select id="tags" multiple value={tags}
        onChange={(e: ChangeEvent<HTMLSelectElement>) => {
          const values = Array.from(e.target.selectedOptions, (o) => o.value);
          setTags(values);
          show("select multiple", values);
        }}>
        <option value="a">A</option>
        <option value="b">B</option>
        <option value="c">C</option>
      </select>

      <textarea id="note" value={note}
        onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { setNote(e.target.value); show("textarea value", e.target.value); }} />

      <input id="file" type="file"
        onChange={(e: ChangeEvent<HTMLInputElement>) => {
          const file = e.target.files?.[0];
          setFileName(file?.name ?? "");
          show("file value", e.target.value);
          show("file name/size/type", file ? [file.name, file.size, file.type] : null);
        }} />
      <output>{fileName}</output>
    </form>
  );
}

それぞれを操作したときの出力です(number の行は要点だけを抜き出しています)。

text value: "abc" (string)
number value: "12" (string)
checkbox checked: true (boolean)
checkbox value: "on" (string)
radio value: "pro" (string)
select value: "blue" (string)
select multiple: ["a","c"] (object)
textarea value: "1行目\n2行目" (string)
file value: "C:\\fakepath\\memo.txt" (string)
file name/size/type: ["memo.txt",11,"text/plain"] (object)

出力から分かることを整理します。

入力 分かったこと
number e.target.value は "12" という文字列
checkbox ON・OFF は checked(boolean)。チェックを入れたときに value を読むと "on" という固定の文字列で、ON・OFF の判定には使えない
radio 同じ name のうち、選ばれたものの value が取れる。制御するときは checked={plan === p} と書く
select(multiple) e.target.value ではなく selectedOptions を配列に変換する
textarea 改行は \n として文字列に含まれる。React では子要素ではなく value 属性で値を渡す
file value はブラウザが付ける見かけ上のパス(C:\fakepath\...)。ファイル本体は files から取る

fileは非制御で扱う

file 入力の値は利用者が選んだファイルで、プログラムから設定できません。そのため value を state で制御することはできず、onChange で e.target.files を読むか、ref・FormData で取り出します。 選択をクリアしたいときは、ref 経由で inputRef.current.value = "" と空文字を代入します(この1行は今回は未実行です)。

同じnameが複数あるときはgetAll

チェックボックスを複数選ぶ項目や、ファイル、送信ボタンの区別を FormData で読む例です。

import type { SubmitEvent } from "react";

export function MultiForm() {
  const handleSubmit = (e: SubmitEvent<HTMLFormElement>) => {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    const file = fd.get("attachment");
    console.log("get(tags):", JSON.stringify(fd.get("tags")));
    console.log("getAll(tags):", JSON.stringify(fd.getAll("tags")));
    console.log("plan:", JSON.stringify(fd.get("plan")));
    console.log("note:", JSON.stringify(fd.get("note")));
    console.log("file:", file instanceof File ? `File name=${file.name} size=${file.size}` : String(file));
    console.log("submitter:", (e.nativeEvent.submitter as HTMLButtonElement | null)?.value);
  };
  return (
    <form onSubmit={handleSubmit}>
      <input type="checkbox" name="tags" value="react" defaultChecked />
      <input type="checkbox" name="tags" value="vue" />
      <input type="checkbox" name="tags" value="ts" defaultChecked />
      <input type="radio" name="plan" value="free" defaultChecked />
      <input type="radio" name="plan" value="pro" />
      <textarea name="note" defaultValue="よろしくお願いします" />
      <input type="file" name="attachment" />
      <button type="submit" name="intent" value="draft">下書き</button>
      <button type="submit" name="intent" value="send">送信</button>
    </form>
  );
}

ファイルを選ばずに「送信」を押したとき、ファイルを選んで「下書き」を押したときの出力です。

get(tags): "react"
getAll(tags): ["react","ts"]
plan: "free"
note: "よろしくお願いします"
file: File name= size=0
submitter: send
--- ファイルを選んでから「下書き」
getAll(tags): ["react","ts"]
file: File name=memo.txt size=11
submitter: draft
  • get は最初の1件だけ、getAll は全件を配列で返します。 複数選択の項目は getAll を使います。
  • ファイルを選んでいなくても、file 入力は名前が空でサイズ 0 の File として入っていました。「選ばれたか」は file.size > 0 などで判定します。
  • どのボタンで送信されたかは e.nativeEvent.submitter から分かります。

「uncontrolled input to be controlled」警告を再現して直す

再現

// 警告が出る例:初期値が undefined
export function WarnUndefined() {
  const [name, setName] = useState<string>();
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

入力欄に1文字入力した時点で、コンソールに次の警告が出ました(React 19.3.0)。

A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components

React は「value が undefined のとき=非制御」「value が文字列のとき=制御」と判断します。 最初の描画では value が undefined なので非制御として始まり、入力後に文字列になったことで「途中から制御に変わった」と知らせてくれています。

直し方

// 直し方1:初期値を空文字にする
const [name, setName] = useState("");
return <input value={name} onChange={(e) => setName(e.target.value)} />;

// 直し方2:後から値が届く場合は ?? "" で必ず文字列にする
const [name, setName] = useState<string | undefined>(user?.name);
return <input value={name ?? ""} onChange={(e) => setName(e.target.value)} />;

どちらも、同じ操作で警告は出なくなりました。value に渡す値が、最初から最後まで文字列であることがポイントです。API から取得したデータを初期値にするフォームでは、取得前の undefined がそのまま value に入りやすいので、直し方2の形が役立ちます。

関連する警告の一覧

同じ系統の警告も再現しました。原因と直し方をまとめます。

書き方 出た警告(先頭部分) 直し方
value が undefined から文字列に変わる A component is changing an uncontrolled input to be controlled. 初期値を "" にする、value={v ?? ""} にする
value が文字列から undefined に変わる A component is changing a controlled input to be uncontrolled. クリアするときは undefined ではなく "" を入れる
value を書いて onChange が無い You provided a 'value' prop to a form field without an 'onChange' handler. This will render a read-only field. onChange を付ける、defaultValue にする、または readOnly を付ける
value に null を渡す 'value' prop on 'input' should not be null. Consider using an empty string to clear the component or 'undefined' for uncontrolled components. "" に置き換える
value と defaultValue を両方書く WarnBoth contains an input of type undefined with both value and defaultValue props. どちらか一方だけにする

表の警告文は、引用符の種類だけ表に書きやすい形に置き換えています。確認できた挙動も2つ補足します。

  • value を書いて onChange が無い入力欄は、キー入力をしても値が変わりませんでした(「固定」と表示されたまま)。「入力できない」と感じたときは、まずここを確認します。
  • 文字列から undefined に変えたとき、入力欄には直前の文字(abc)が残ったままでした。入力欄を空にしたいときは空文字を入れるのが確実です。

number入力が文字列で返る挙動と直し方

再現

type="number" にしても、e.target.value は文字列です。入力内容ごとの値を確かめました。

入力した内容 e.target.value e.target.valueAsNumber
12 "12"(string) 12(number)
空にする ""(string) NaN
1.50 "1.50"(string) 1.5
1e(入力の途中) ""(string) NaN
1e3 "1e3"(string) 1000
abc をキー入力 入力欄は空のまま(文字は入らない) ―

number 入力は「数値として解釈できない状態」のとき、value が空文字になります。 文字列を数値に変換する関数ごとの違いも見ておきます。

式 結果
Number("") 0
parseInt("", 10) NaN
Number("12px") NaN
parseInt("12px", 10) 12
parseFloat("1.50") 1.5

Number("") が 0 になるため、空欄と 0 の区別がつかなくなる点が要注意です。

stateを数値で持つと起こること

state を number 型にして、onChange で Number(e.target.value) を入れる書き方を試しました。

const [qty, setQty] = useState(1);
<input type="number" value={qty} onChange={(e) => setQty(Number(e.target.value))} />
操作 state 入力欄の表示
空にしてから 5 をキー入力 5 05
空にする 0 0
空にしてから 12 をキー入力 12 012

入力欄を空にした瞬間に state が 0 になり、入力欄にも 0 が表示されます。続けて 5 を入力すると、表示が「05」になりました。 state の値(5)と入力欄の文字列(05)は数値として等しいので、React は入力欄を書き換えません。

直し方:stateは文字列で持ち、使うときに数値へ変換する

export function QuantityInput() {
  const [qty, setQty] = useState("1");
  const n = qty === "" ? null : Number(qty); // 空欄は null として区別する
  return (
    <>
      <input type="number" value={qty} onChange={(e) => setQty(e.target.value)} />
      <output>{n === null ? "未入力" : `${n} 個(${typeof n})`}</output>
    </>
  );
}
入力した内容 表示
5 5 個(number)
空にする 未入力
007 7 個(number)
2.5 2.5 個(number)

入力欄の value は文字列のまま state に入れ、計算や送信の直前に数値へ変換すると、空欄も途中の入力もそのまま扱えます。非制御で読む場合は、ref.current.valueAsNumber を使うと数値で受け取れます(空欄のときは NaN になるので、Number.isNaN で確認します)。

イベントの型をTypeScriptで書く

場面 型の書き方
input の onChange ChangeEvent<HTMLInputElement>
select の onChange ChangeEvent<HTMLSelectElement>
textarea の onChange ChangeEvent<HTMLTextAreaElement>
複数の要素で共通のハンドラ ChangeEvent<HTMLInputElement | HTMLSelectElement>
form の onSubmit SubmitEvent<HTMLFormElement>
form action の関数 引数は FormData
ref useRef<HTMLInputElement>(null)

インラインで onChange={(e) => ...} と書く場合は、型が自動で推論されるので注釈は不要です。ハンドラを別の関数として切り出すときに、上の型を書きます。

送信イベントの型については、今回インストールした @types/react 19.3.0 の型定義を開いて確認したところ、FormEvent には非推奨(deprecated)の印が付き、ChangeEvent・InputEvent・SubmitEvent などを使うよう案内するコメントが書かれていました。SubmitEvent には submitter プロパティも定義されています。この記事のコードは SubmitEvent<HTMLFormElement> で書き、tsc --noEmit(strict)でエラーが出ないことを確認しています。SubmitEvent が無いバージョンの型定義を使っている場合は、FormEvent<HTMLFormElement> と書けば同じように動きます。

e.targetとe.currentTargetの違い

プロパティ 指すもの 使いどころ
e.target イベントが実際に起きた要素 onChange で入力欄の値を読む
e.currentTarget ハンドラを付けた要素 onSubmit で form 要素を FormData に渡す

onSubmit の中では、new FormData(e.currentTarget) と書くと、型が HTMLFormElement に決まるので扱いやすくなります。

React 19:送信中の表示と結果をuseActionStateで扱う

form action と組み合わせると、送信中かどうかと、送信結果のメッセージを1つのフックで扱えます。

import { useActionState } from "react";

type State = { message: string };

async function save(_prev: State, fd: FormData): Promise<State> {
  const title = String(fd.get("title") ?? "").trim();
  if (title === "") return { message: "件名を入力してください" };
  await new Promise((r) => setTimeout(r, 100)); // 送信の代わりに 0.1 秒待つ
  return { message: `「${title}」を受け付けました` };
}

export function ActionStateForm() {
  const [state, formAction, isPending] = useActionState(save, { message: "" });
  return (
    <form action={formAction}>
      <input name="title" />
      <button type="submit" disabled={isPending}>{isPending ? "送信中..." : "申請"}</button>
      <p>{state.message}</p>
    </form>
  );
}
操作 結果
空のまま送信 メッセージ「件名を入力してください」
「椅子」と入力して送信した直後 ボタンの表示が「送信中...」になり、押せない状態(disabled)になる
完了後 メッセージ「「椅子」を受け付けました」、ボタンは「申請」に戻り、入力欄は空に戻る

useState で「送信中フラグ」と「メッセージ」を別々に持たなくても、同じことが書けます。 サーバーへ実際に送信する処理や、Next.js のサーバーアクションとの組み合わせは今回は未実行です。

つまずきやすい点のチェックリスト

症状 確認すること
入力しても文字が入らない value を書いて onChange を付け忘れていないか。onChange の中で state を更新しているか
送信するとページが再読み込みされる onSubmit の中で e.preventDefault() を呼んでいるか
uncontrolled to controlled の警告 state の初期値が undefined になっていないか。value={v ?? ""} にする
チェックボックスの値が常に "on" value ではなく checked を読む
数値のつもりが文字列で連結される e.target.value は文字列。Number() で変換してから計算する
数値入力で「05」のように表示される state を文字列で持ち、使うときに数値へ変換する
FormData にチェックボックスが入っていない 未チェックの項目は含まれない。fd.has() や === "on" で判定する
FormData で値が取れない 入力欄に name 属性を付けたか
ref.current が null ref を入力欄の ref 属性に渡したか。読むのは描画後(イベントの中)か
form action で入力欄が空に戻る 仕様どおりの挙動。残したい項目は制御コンポーネントにする

よくある質問

Q. onChange と useRef はどちらを使えばよいですか? A. 入力中の値を画面に反映したい(文字数カウント、入力に応じたボタンの有効・無効、入力中の検証など)なら useState+onChange、送信時に値が読めれば足りるなら useRef や FormData が向いています。迷ったら、まず FormData で書き、入力中の値が必要になった項目だけ制御コンポーネントにする進め方もあります。

Q. 制御コンポーネントは入力のたびに再レンダリングされますが、問題ありませんか? A. 項目数が一般的なフォームであれば、そのままで軽快に動くことがほとんどです。項目が非常に多い、または1回の描画が重い画面で気になったときは、入力欄を小さなコンポーネントに分ける、非制御に切り替える、フォーム用ライブラリを使う、といった選択肢があります。

Q. onChange は1文字ごとに呼ばれますか? A. はい。React の onChange は、ブラウザ標準の change イベント(フォーカスが外れたときに発生)とは異なり、入力のたびに呼ばれます。今回の確認でも、number 入力に「1.50」と1文字ずつ入力すると、値が「1」「1.5」「1.50」と変わるたびに onChange が呼ばれていました。

Q. React 19 の form action は React 18 でも使えますか? A. 関数を渡す書き方は React 19 からです。React 18.3.1 で描画すると Invalid value for prop action の警告が出ました。React 18 では、onSubmit と FormData の組み合わせ(③)が近い書き心地になります。

まとめ

  • input の値を取る方法は、制御(useState+onChange)と非制御(useRef・FormData・form action)の2系統
  • 読むプロパティは、ほとんどが value、checkbox は checked、file は files。 select の複数選択は selectedOptions を配列にする
  • 入力中の値を使うなら useState+onChange、送信時だけなら FormData か form action が短く書ける
  • FormData の値はすべて文字列で、未チェックの checkbox は含まれない。 複数の値は getAll で取る
  • React 19 の form action は preventDefault が不要で、完了後に非制御の入力欄を初期値へ戻す
  • 「uncontrolled input to be controlled」警告は value が undefined から文字列に変わると出る。 初期値を空文字にするか ?? "" で直る
  • number 入力の value は文字列。 state は文字列で持ち、使うときに数値へ変換すると「05」や空欄の扱いで迷わない

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Reactフォームinputの値取得|onChangeとuseRefの使い分け」

株式会社RJCでは、React のフォームと入力値の扱い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?