- React
Reactフォームinputの値取得|onChangeとuseRefの使い分け
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期連続の黒字決算。