- React
React onClickに引数を渡す書き方とイベントハンドラ早見表
ReactのonClickに引数を渡すには、onClick={() => fn(id)} のようにアロー関数で包んで「関数そのもの」を渡します。 onClick={fn(id)} と書くと、クリックを待たずに描画の途中で fn(id) が呼ばれ、その戻り値がonClickに入ります。イベントハンドラの書き方でつまずく場面の多くは、この「渡す」と「呼ぶ」の違いから説明できます。
この記事では、4通りの書き方を同じ画面に並べて動かし、いつ・どんな引数で関数が呼ばれるかを出力で比べます。 あわせて Too many re-renders の再現と直し方、onClick・onChange・onSubmit・onKeyDownなど主要イベントとTypeScriptの型の対応表、preventDefaultとstopPropagationの違い、カリー化やdata属性での値の受け渡しまで、1本でたどれるようにまとめました。掲載したコードはすべてVitestとTesting Libraryで動かし、出力をそのまま載せています。
確認環境:Ubuntu 24.04、Node.js 22.22.2、React 19.3.0 / react-dom 19.3.0、TypeScript 7.0.2、@types/react 19.3.0、Vitest 5.0.3、jsdom 30.1.1、@testing-library/react 16.3.3、@testing-library/user-event 14.6.7。
onClickの書き方とイベントハンドラの早見表
まず、onClickへの渡し方4通りです。
| 書き方 | 関数が呼ばれるタイミング | 関数に届く引数 | 使いどころ |
|---|---|---|---|
onClick={fn} |
クリックしたとき | eventオブジェクト | 引数がいらない、またはeventだけ使う |
onClick={fn()} |
描画のたび(クリックでは呼ばれない) | なし | 関数を返す関数(カリー化)のときだけ |
onClick={() => fn(id)} |
クリックしたとき | id |
自分で決めた値を渡す。基本はこれ |
onClick={(e) => fn(e, id)} |
クリックしたとき | eventと id |
eventも値も両方使う |
次に、主要なイベントとTypeScriptの型の対応です(型はすべて react からimportします)。
| 属性 | 動くタイミング | eventの型 | ハンドラの型 |
|---|---|---|---|
onClick |
クリック | MouseEvent |
MouseEventHandler |
onChange |
入力値が変わるたび | ChangeEvent |
ChangeEventHandler |
onSubmit |
フォーム送信 | SubmitEvent(従来は FormEvent) |
SubmitEventHandler |
onKeyDown |
キーを押したとき | KeyboardEvent |
KeyboardEventHandler |
onFocus / onBlur |
フォーカスが入る/外れる | FocusEvent |
FocusEventHandler |
| やりたいこと | 書くもの |
|---|---|
| フォーム送信でページが再読み込みされるのを止める | e.preventDefault() |
| 親要素のonClickまで動くのを止める | e.stopPropagation() |
| 入力値を取り出す | e.target.value(チェックボックスは e.target.checked) |
| ハンドラを付けた要素そのものを取り出す | e.currentTarget |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React onClickに引数を渡す書き方とイベントハンドラ早見表」
コピペで動かすための準備
この記事のコードは、次の構成で動かしています。空のフォルダで順に実行すると同じ環境になります。
npm init -y
npm i react react-dom typescript vitest jsdom \
@testing-library/react @testing-library/dom @testing-library/user-event \
@types/react @types/react-dom
// vitest.config.mts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "jsdom", globals: true } });
{
"compilerOptions": {
"target": "ES2023", "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "moduleResolution": "Bundler",
"jsx": "react-jsx", "strict": true, "skipLibCheck": true, "noEmit": true,
"types": ["vitest/globals"]
},
"include": ["a1/ok"]
}
テストファイルの先頭は共通です。各例では log という配列に文字列をため、最後に out() でまとめて表示しています。
// a1/ok/events.test.tsx の先頭
import { useState } from "react";
import type { MouseEvent, ChangeEvent, FormEvent, KeyboardEvent } from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
afterEach(cleanup);
const log: string[] = [];
const out = (title: string) => { console.log(`--- ${title}\n` + log.join("\n")); log.length = 0; };
実行は npx vitest run a1 --silent=false、型チェックは npx tsc --noEmit です。
4通りの書き方を並べて動かす
4つのボタンに同じ関数 fn を、書き方だけ変えて渡します。 fn は受け取った引数を記録するだけの関数です。
test("1. 4通りの書き方", async () => {
const fn = (...args: unknown[]) => {
const shown = args.map((a) => (a && typeof a === "object" && "type" in a ? `event(${(a as { type: string }).type})` : JSON.stringify(a)));
log.push(` fn(${shown.join(", ")}) が呼ばれた`);
};
function App() {
const id = 7;
log.push("render 開始");
const el = (
<>
<button onClick={fn}>A</button>
{/* @ts-expect-error 戻り値 void を渡している */}
<button onClick={fn()}>B</button>
<button onClick={() => fn(id)}>C</button>
<button onClick={(e) => fn(e, id)}>D</button>
</>
);
log.push("render 終了");
return el;
}
render(<App />);
for (const name of ["A", "B", "C", "D"]) {
log.push(`${name} をクリック`);
await userEvent.click(screen.getByText(name));
}
out("4通り");
});
実行結果:
--- 4通り
render 開始
fn() が呼ばれた
render 終了
A をクリック
fn(event(click)) が呼ばれた
B をクリック
C をクリック
fn(7) が呼ばれた
D をクリック
fn(event(click), 7) が呼ばれた
出力から読み取れることを整理します。
| ボタン | 結果 | 理由 |
|---|---|---|
A onClick={fn} |
クリックで呼ばれ、引数はevent | Reactは渡された関数に、eventオブジェクトを1つ付けて呼ぶ |
B onClick={fn()} |
描画の途中で1回呼ばれ、クリックしても何も起きない | fn() はその場で実行される式。onClickに入るのは戻り値の undefined |
C onClick={() => fn(id)} |
クリックで呼ばれ、引数は 7 |
アロー関数が「クリックされたら fn(id) を呼ぶ関数」になっている |
D onClick={(e) => fn(e, id)} |
クリックで呼ばれ、引数はeventと 7 |
アロー関数の引数 e で受けて、好きな順番で渡せる |
JSXの {} の中はJavaScriptの式です。 fn は関数そのもの、fn() は「関数を呼んだ結果」です。onClickが欲しいのは「あとで呼ぶための関数」なので、引数を付けたいときはアロー関数でもう一段包みます。
Aの書き方で fn が (id: number) => void のように数値を期待していると、届くのはeventなので意図と違う値が入ります。TypeScriptではこの取り違えを型エラーとして教えてくれます(後述の「型エラーの読み方」で出力を載せています)。
Too many re-rendersを再現して原因を見る
onClick={setCount(count + 1)} のように、stateを更新する関数を「呼んで」渡すと、描画のたびにstateが更新され、Reactが描画を打ち切ります。
test("2. Too many re-renders", () => {
function Counter() {
const [count, setCount] = useState(0);
// @ts-expect-error 再現用
return <button onClick={setCount(count + 1)}>{count}</button>;
}
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
try { render(<Counter />); } catch (e) { log.push(`${(e as Error).name}: ${(e as Error).message}`); }
spy.mockRestore();
out("too many");
function Fixed() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
render(<Fixed />);
fireEvent.click(screen.getByRole("button"));
fireEvent.click(screen.getByRole("button"));
log.push(`直した後: 2回クリック → 表示は ${screen.getByRole("button").textContent}`);
out("fixed");
});
実行結果:
--- too many
Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
--- fixed
直した後: 2回クリック → 表示は 2
流れは次のとおりです。
flowchart TD
A[Counterを描画] --> B["setCount(count + 1) がその場で実行される"]
B --> C[stateが変わる]
C --> D[Reactがもう一度Counterを描画]
D --> B
D --> E[回数の上限でReactがエラーを出して止める]
直し方は、アロー関数で包むだけです。
| 直す前 | 直した後 |
|---|---|
onClick={setCount(count + 1)} |
onClick={() => setCount(count + 1)} |
onClick={handleDelete(id)}(中でsetStateしている) |
onClick={() => handleDelete(id)} |
onChange={setName(e.target.value)} |
onChange={(e) => setName(e.target.value)} |
エラー文に Too many re-renders と出たら、まずJSXの中で onClick={何か(...)} と丸かっこ付きで書いている箇所を探すと、原因にたどり着きやすくなります。TypeScriptを使っていれば、実行する前に次のエラーで気づけます。
a1/ng/ng.tsx(16,15): error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.
「onClickには関数が必要なのに、void(関数の戻り値)が渡されている」という意味です。
eventオブジェクトの中身を確認する
ハンドラに届くeventは、ブラウザのイベントをReactが包んだオブジェクトです。よく使う項目を出力してみます。
test("3. event の中身", async () => {
function App() {
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
log.push(`type=${e.type}`);
log.push(`target=${(e.target as HTMLElement).tagName} currentTarget=${e.currentTarget.tagName}`);
log.push(`constructor=${e.constructor.name} nativeEvent=${e.nativeEvent.constructor.name}`);
log.push(`clientX は数値か=${typeof e.clientX} shiftKey=${e.shiftKey}`);
};
return <button onClick={handleClick}><span>保存</span></button>;
}
render(<App />);
await userEvent.click(screen.getByText("保存"));
out("event");
});
実行結果:
--- event
type=click
target=SPAN currentTarget=BUTTON
constructor=SyntheticBaseEvent nativeEvent=PointerEvent
clientX は数値か=number shiftKey=false
| 項目 | 中身 | メモ |
|---|---|---|
e.target |
実際にクリックされた要素(ここでは内側の span) |
子要素になることがある |
e.currentTarget |
ハンドラを付けた要素(button) |
data属性などを読むときはこちら |
e.nativeEvent |
ブラウザ本来のイベント | Reactが包む前のオブジェクト |
e.type |
イベントの種類 | click、change など |
e.preventDefault() |
ブラウザの既定の動作を止める | 送信・リンク移動など |
e.stopPropagation() |
親への伝播を止める | 親のonClickが動かなくなる |
ボタンの中にアイコンや span を入れると、e.target は内側の要素になります。「ハンドラを付けた要素」が欲しいときは e.currentTarget を使うと、中身の構造が変わっても同じコードで動きます。
主要イベントとTypeScriptの型の対応表
@types/react 19.3.0 の型定義(node_modules/@types/react/index.d.ts)で、各属性に付いているハンドラの型を確認した結果です。
| 属性 | eventの型 | ハンドラの型 | よく使う項目 |
|---|---|---|---|
onClick / onDoubleClick / onContextMenu |
MouseEvent |
MouseEventHandler |
clientX、shiftKey、currentTarget |
onMouseEnter |
MouseEvent |
MouseEventHandler |
relatedTarget |
onChange |
ChangeEvent |
ChangeEventHandler |
target.value、target.checked |
onInput |
InputEvent |
InputEventHandler |
currentTarget.value |
onSubmit |
SubmitEvent |
SubmitEventHandler |
currentTarget、nativeEvent.submitter |
onKeyDown |
KeyboardEvent |
KeyboardEventHandler |
key、ctrlKey、shiftKey |
onFocus / onBlur |
FocusEvent |
FocusEventHandler |
relatedTarget |
onPointerDown |
PointerEvent |
PointerEventHandler |
pointerType、pointerId |
onTouchStart |
TouchEvent |
TouchEventHandler |
touches |
onWheel |
WheelEvent |
WheelEventHandler |
deltaY |
onScroll |
UIEvent |
UIEventHandler |
currentTarget.scrollTop |
onDragStart |
DragEvent |
DragEventHandler |
dataTransfer |
onCopy |
ClipboardEvent |
ClipboardEventHandler |
clipboardData |
型の書き方は2通りあり、どちらでも同じ結果になります。
import type { MouseEvent, MouseEventHandler } from "react";
// (1) 引数に型を付ける
const handleClick = (e: MouseEvent<HTMLButtonElement>) => { console.log(e.currentTarget.name); };
// (2) 関数全体に型を付ける(引数 e の型は自動で決まる)
const handleClick2: MouseEventHandler<HTMLButtonElement> = (e) => { console.log(e.currentTarget.name); };
山かっこの中には、ハンドラを付ける要素の型(HTMLButtonElement、HTMLInputElement、HTMLFormElement など)を書きます。JSXの中に直接 onClick={(e) => ...} と書いた場合は、型を書かなくても e の型が自動で決まります。型を書く必要があるのは、ハンドラをJSXの外で定義したときです。
FormEventとSubmitEventについて
onSubmitの型は、これまで FormEvent と書くのが一般的でした。@types/react 19.3.0 の型定義では、FormEvent に次の注記が付いています。
@deprecated FormEvent doesn't actually exist.
You probably meant to use ChangeEvent, InputEvent, SubmitEvent, or just SyntheticEvent instead
depending on the event type.
同じ型定義で、onSubmit 属性の型は SubmitEventHandler になっています。手元では FormEvent で書いたハンドラも SubmitEvent で書いたハンドラも tsc --noEmit を通りました。これから書くコードは SubmitEvent、すでにある FormEvent はそのまま動く、と覚えておけば十分です。SubmitEvent では e.nativeEvent.submitter で「どのボタンで送信されたか」も型付きで読めます。
onChange・onKeyDown・onSubmitをフォームで使う
入力欄、セレクトボックス、チェックボックス、送信ボタンを1つのフォームにまとめた例です。
test("4. onChange / onKeyDown / onSubmit", async () => {
function App() {
const [name, setName] = useState("");
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { log.push(`change: value=${e.target.value}`); setName(e.target.value); };
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") log.push(`keydown: key=${e.key}`); };
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
log.push(`submit: defaultPrevented=${e.defaultPrevented} name=${data.get("name")} state=${name}`);
};
const handleSelect = (e: ChangeEvent<HTMLSelectElement>) => log.push(`select: value=${e.target.value}`);
const handleCheck = (e: ChangeEvent<HTMLInputElement>) => log.push(`checkbox: checked=${e.target.checked}`);
return (
<form onSubmit={handleSubmit}>
<input name="name" value={name} onChange={handleChange} onKeyDown={handleKeyDown} />
<select name="size" onChange={handleSelect} defaultValue="s"><option value="s">S</option><option value="m">M</option></select>
<label><input type="checkbox" onChange={handleCheck} />同意</label>
<button type="submit">送信</button>
</form>
);
}
render(<App />);
await userEvent.type(screen.getByRole("textbox"), "ab{Enter}");
await userEvent.click(screen.getByText("送信"));
await userEvent.selectOptions(screen.getByRole("combobox"), "m");
await userEvent.click(screen.getByRole("checkbox"));
out("form");
});
実行結果:
--- form
change: value=a
change: value=ab
keydown: key=Enter
submit: defaultPrevented=true name=ab state=ab
submit: defaultPrevented=true name=ab state=ab
select: value=m
checkbox: checked=true
ポイントは次の4つです。
- ReactのonChangeは、1文字入力するたびに動きます。
a、abと2回出ているのがその様子です。 - 入力欄でEnterを押すと、フォームのonSubmitが動きます。 submitが2行あるのは、Enterキーで1回、送信ボタンのクリックで1回動いたためです。onKeyDownでEnterを拾って送信処理を書かなくても、onSubmitだけで両方に対応できます。
- onSubmitの最初に
e.preventDefault()を書くと、ブラウザ既定のページ再読み込みが止まり、defaultPrevented=trueになります。 - チェックボックスは
e.target.checkedを読みます。オン・オフが真偽値で届きます。
入力値の取り出し方をまとめます。
| 要素 | 読む項目 | 値の型 |
|---|---|---|
input(text、email など) |
e.target.value |
文字列 |
input type="checkbox" |
e.target.checked |
真偽値 |
select |
e.target.value |
文字列 |
textarea |
e.target.value |
文字列 |
form(送信時) |
new FormData(e.currentTarget) |
name属性ごとの値 |
型エラーの読み方 よく出るエラー
型の取り違えを1ファイルにまとめ、tsc --noEmit にかけました。
// a1/ng/ng.tsx
import { useState } from "react";
import type { MouseEvent, ChangeEvent, SubmitEvent } from "react";
export function Ng() {
const [count, setCount] = useState(0);
const remove = (id: number) => console.log(id);
// (1) 型注釈なしで外に出した関数
const handleChange = (e) => console.log(e.target.value);
// (2) 要素の型が合わない
const handleInput = (e: ChangeEvent<HTMLInputElement>) => console.log(e.target.value);
// (3) DOM の MouseEvent と取り違え
const handleNative = (e: globalThis.MouseEvent) => console.log(e.clientX);
// (4) 引数つきの関数をそのまま渡す
return (
<>
<button onClick={setCount(count + 1)}>{count}</button>
<input onChange={handleChange} />
<select onChange={handleInput} />
<button onClick={handleNative}>x</button>
<button onClick={remove}>y</button>
</>
);
}
export function Sub() {
const h = (e: SubmitEvent<HTMLFormElement>) => { e.preventDefault(); console.log(e.nativeEvent.submitter); };
const m = (e: MouseEvent<HTMLButtonElement>) => { const v: string = e.currentTarget.dataset.id; console.log(v); };
return <form onSubmit={h}><button onClick={m} /></form>;
}
実行結果(npx tsc --noEmit -p a1/tsconfig.ng.json):
a1/ng/ng.tsx(8,25): error TS7006: Parameter 'e' implicitly has an 'any' type.
a1/ng/ng.tsx(16,15): error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.
a1/ng/ng.tsx(18,15): error TS2322: Type '(e: ChangeEvent<HTMLInputElement>) => void' is not assignable to type 'ChangeEventHandler<HTMLSelectElement, HTMLSelectElement>'.
Types of parameters 'e' and 'event' are incompatible.
Type 'ChangeEvent<HTMLSelectElement, HTMLSelectElement>' is not assignable to type 'ChangeEvent<HTMLInputElement, Element>'.
Type 'HTMLSelectElement' is missing the following properties from type 'HTMLInputElement': accept, align, alt, capture, and 38 more.
a1/ng/ng.tsx(19,15): error TS2322: Type '(e: globalThis.MouseEvent) => void' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'.
Types of parameters 'e' and 'event' are incompatible.
Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is missing the following properties from type 'MouseEvent': layerX, layerY, offsetX, offsetY, and 16 more.
a1/ng/ng.tsx(20,15): error TS2322: Type '(id: number) => void' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'.
Types of parameters 'id' and 'event' are incompatible.
Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is not assignable to type 'number'.
a1/ng/ng.tsx(26,59): error TS2322: Type 'string | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
| エラー | 意味 | 直し方 |
|---|---|---|
TS7006 Parameter 'e' implicitly has an 'any' type |
JSXの外で定義した関数は、e の型が自動で決まらない |
(e: ChangeEvent<HTMLInputElement>) と型を書く |
TS2322 Type 'void' is not assignable to ... |
関数を呼んで戻り値を渡している | () => setCount(count + 1) とアロー関数で包む |
TS2322 HTMLSelectElement is missing ... |
山かっこの中の要素型が、付けた要素と違う | ChangeEvent<HTMLSelectElement> に合わせる |
TS2322 missing ... layerX, layerY, offsetX |
ブラウザ本来の MouseEvent を使っている |
import type { MouseEvent } from "react" を足す |
TS2322 MouseEvent ... is not assignable to type 'number' |
数値を受ける関数にeventが届く | () => remove(id) とアロー関数で包む |
TS2322 Type 'undefined' is not assignable to type 'string' |
dataset.id は属性が無い場合がある |
?? "" で既定値を決める、または Number() で変換して判定する |
4つ目の取り違えは、MouseEvent という名前がブラウザ標準にもReactにもあるために起こります。importを書き忘れると、ブラウザ標準の型が使われるので、エラー文に layerX や offsetX が出てきたらimportを確認します。
preventDefaultとstopPropagationの違い
名前が似ていますが、止める対象が別です。
| メソッド | 止めるもの | 止めないもの | 代表的な使いどころ |
|---|---|---|---|
e.preventDefault() |
ブラウザ既定の動作(送信、リンク移動、チェックの切り替えなど) | 親要素への伝播 | フォーム送信を自分のコードで処理する |
e.stopPropagation() |
親要素のハンドラが動くこと | ブラウザ既定の動作 | カード全体がクリックできる中の「削除」ボタン |
親の div、form、送信ボタンを入れ子にして、3つのモードで比べます。
test("5. preventDefault と stopPropagation", async () => {
function App({ mode }: { mode: "none" | "prevent" | "stop" }) {
return (
<div onClick={() => log.push(" 親 div の onClick")}>
<form onSubmit={() => log.push(" form の onSubmit(送信が始まる)")}>
<button
type="submit"
onClick={(e) => {
if (mode === "prevent") e.preventDefault();
if (mode === "stop") e.stopPropagation();
log.push(" button の onClick");
}}
>送信</button>
</form>
</div>
);
}
for (const mode of ["none", "prevent", "stop"] as const) {
log.push(`mode=${mode}`);
const { unmount } = render(<App mode={mode} />);
await userEvent.click(screen.getByText("送信"));
unmount();
}
out("prevent/stop");
function Check({ prevent }: { prevent: boolean }) {
return <input type="checkbox" onClick={(e) => { if (prevent) e.preventDefault(); }} />;
}
for (const prevent of [false, true]) {
const { unmount } = render(<Check prevent={prevent} />);
await userEvent.click(screen.getByRole("checkbox"));
log.push(`preventDefault=${prevent} → checked=${(screen.getByRole("checkbox") as HTMLInputElement).checked}`);
unmount();
}
function Link() {
return <a href="/next" onClick={(e) => { e.preventDefault(); log.push(`リンク: defaultPrevented=${e.defaultPrevented} isDefaultPrevented()=${e.isDefaultPrevented()} isPropagationStopped()=${e.isPropagationStopped()}`); }}>次へ</a>;
}
render(<Link />);
await userEvent.click(screen.getByText("次へ"));
out("checkbox/link");
});
実行結果:
--- prevent/stop
mode=none
button の onClick
親 div の onClick
form の onSubmit(送信が始まる)
mode=prevent
button の onClick
親 div の onClick
mode=stop
button の onClick
form の onSubmit(送信が始まる)
--- checkbox/link
preventDefault=false → checked=true
preventDefault=true → checked=false
リンク: defaultPrevented=true isDefaultPrevented()=true isPropagationStopped()=false
- 何もしない場合:ボタン → 親のdiv の順にonClickが動き、そのあとフォームの送信が始まります。
- preventDefault:親のonClickは動きますが、送信(既定の動作)は始まりません。
- stopPropagation:親のonClickは動かなくなりますが、送信は始まります。伝播を止めても、既定の動作は止まらないことがわかります。
- チェックボックスのonClickで
preventDefault()を呼ぶと、チェックが入りません。リンクではpreventDefault()でページ移動を止め、自分の処理に置き換えられます。
両方止めたいときは、2つとも呼びます。
伝播の順番とonClickCapture
Reactのイベントは、内側の要素から外側へ順に伝わります。onClickCapture のように Capture を付けた属性は、その逆で外側から先に動きます。
test("7. 伝播の順序とキャプチャ", async () => {
function App() {
return (
<div onClickCapture={() => log.push("1 親 onClickCapture")} onClick={() => log.push("4 親 onClick")}>
<button onClickCapture={() => log.push("2 子 onClickCapture")} onClick={() => log.push("3 子 onClick")}>押す</button>
</div>
);
}
render(<App />);
await userEvent.click(screen.getByText("押す"));
out("capture");
});
実行結果:
--- capture
1 親 onClickCapture
2 子 onClickCapture
3 子 onClick
4 親 onClick
React公式ドキュメント(Responding to Events)には、onScrollだけは伝播せず、付けた要素でのみ動くと書かれています。また、キャプチャは主にルーターや計測のコード向けで、アプリのコードで必要になる場面は多くないとも説明されています。
引数を渡す3つの方法 アロー関数・カリー化・data属性
一覧の各行に「削除」ボタンがあり、どの行かを示す id を渡したい、という定番の場面で比べます。
test("6. カリー化と data 属性", async () => {
const items = [{ id: 1, name: "ノート" }, { id: 2, name: "ペン" }];
function Curry() {
const handleDelete = (id: number) => (e: MouseEvent<HTMLButtonElement>) => log.push(`curry: id=${id} (${typeof id}) type=${e.type}`);
return <ul>{items.map((it) => <li key={it.id}><button onClick={handleDelete(it.id)}>{it.name}を削除</button></li>)}</ul>;
}
function Data() {
const handleDelete = (e: MouseEvent<HTMLButtonElement>) => {
const id = e.currentTarget.dataset.id;
log.push(`data: id=${JSON.stringify(id)} (${typeof id}) Number(id)=${Number(id)}`);
};
return <ul>{items.map((it) => <li key={it.id}><button data-id={it.id} onClick={handleDelete}><span>{it.name}</span>を消す</button></li>)}</ul>;
}
function Delegate() {
const handleClick = (e: MouseEvent<HTMLUListElement>) => {
const btn = (e.target as HTMLElement).closest("button");
log.push(`delegate: target=${(e.target as HTMLElement).tagName} dataset.id=${btn?.dataset.id}`);
};
return <ul onClick={handleClick}>{items.map((it) => <li key={it.id}><button data-id={it.id}><span>{it.name}</span>を選ぶ</button></li>)}</ul>;
}
render(<><Curry /><Data /><Delegate /></>);
await userEvent.click(screen.getByText("ペンを削除"));
await userEvent.click(screen.getAllByText("ペン")[0]);
await userEvent.click(screen.getAllByText("ペン")[1]);
out("curry/data");
});
実行結果:
--- curry/data
curry: id=2 (number) type=click
data: id="2" (string) Number(id)=2
delegate: target=SPAN dataset.id=2
カリー化(関数を返す関数)
handleDelete(it.id) は一見「呼んでいる」形ですが、呼んだ結果として関数が返ってくるので、onClickには関数が入ります。早見表で onClick={fn()} の使いどころを「関数を返す関数のときだけ」と書いたのは、この形のことです。id は数値のまま届き、eventも受け取れます。
data属性
data-id={it.id} で要素に値を持たせ、ハンドラの中で e.currentTarget.dataset.id から読みます。出力のとおり、data属性から読んだ値は文字列になります。数値として使うなら Number() で変換します。ボタンの中の span をクリックしても、e.currentTarget を使っているのでボタンのdata属性を読めています。
3つ目の例は、親の ul に1つだけハンドラを置き、e.target から closest("button") で押されたボタンを探す形です。このときは e.target が span になっているため、closest でボタンまでさかのぼっています。
どれを選ぶか
| 方法 | 書き方 | 値の型 | eventの受け取り | 向いている場面 |
|---|---|---|---|---|
| アロー関数 | onClick={() => remove(it.id)} |
そのまま | (e) => remove(e, it.id) |
迷ったらこれ。読みやすい |
| カリー化 | onClick={remove(it.id)} |
そのまま | 返す関数の引数で受ける | 同じ形のハンドラを何か所にも付ける |
| data属性 | data-id={it.id} と e.currentTarget.dataset.id |
文字列になる | そのまま受け取れる | 親に1つだけハンドラを置きたい、文字列で足りる |
オブジェクトや配列をそのまま渡せるのがアロー関数とカリー化の良いところで、data属性は文字列にできる値(idやキー名)に向いています。
よくある質問
Q. onClick={handleClick} と onClick={() => handleClick()} はどちらがよいですか?
A. 引数がいらないなら onClick={handleClick} が短く書けます。自分で決めた値を渡すなら onClick={() => handleClick(id)} です。前者はeventが引数として届き、後者は届かない、という違いだけ覚えておくと使い分けられます。
Q. クリックしていないのに関数が動きます。なぜですか?
A. onClick={fn()} や onClick={fn(id)} と丸かっこ付きで書くと、描画のときに関数が実行されます。onClick={() => fn(id)} に直すと、クリックしたときだけ動きます。中でstateを更新していると Too many re-renders になります。
Q. onSubmitの型はFormEventとSubmitEventのどちらを書けばよいですか?
A. @types/react 19.3.0 では onSubmit 属性の型が SubmitEventHandler になっており、FormEvent には非推奨の注記が付いています。新しく書くコードは SubmitEvent、既存の FormEvent はそのまま型チェックを通ります。
Q. e.target.value で型エラーになります。
A. JSXの外で定義したハンドラは、引数に ChangeEvent<HTMLInputElement> のような型を書く必要があります。セレクトボックスなら HTMLSelectElement、テキストエリアなら HTMLTextAreaElement に合わせます。onClickなど ChangeEvent 以外では、e.currentTarget を使うと要素の型が付きます。
まとめ
- 引数を渡すときは
onClick={() => fn(id)}。onClick={fn(id)}は描画の途中で呼ばれる。 - eventも使うなら
onClick={(e) => fn(e, id)}。onClick={fn}と書くと引数はeventになる。 Too many re-rendersは、JSXの中でstateを更新する関数を「呼んで」渡したときに出る。アロー関数で包めば直る。- 型は
reactからimportする。onClickはMouseEvent、onChangeはChangeEvent、onSubmitはSubmitEvent、onKeyDownはKeyboardEvent。 - preventDefaultは既定の動作、stopPropagationは親への伝播を止める。片方を呼んでも、もう片方は止まらない。
- ハンドラを付けた要素は
e.currentTarget。e.targetは内側の子要素になることがある。 - data属性から読んだ値は文字列。数値や複雑な値はアロー関数かカリー化で渡す。
参考
- React公式ドキュメント Responding to Events(https://react.dev/learn/responding-to-events)
- @types/react 19.3.0 の型定義(index.d.ts)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「React onClickに引数を渡す書き方とイベントハンドラ早見表」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。