ReactのonClickで、押していないのに関数が動く理由
React で <button onClick={handleSave()}> と書くと、handleSave はボタンを押したときではなく、コンポーネントが描画(レンダー)されるたびに呼ばれます。保存ボタンや送信ボタンを押していないのに、画面を開いただけで保存の処理が走ったり、ログが出たりするのはこのためです。onClick={handleSave} と、() を付けずに関数そのものを渡すと直ります。
なぜ「描画のたびに」なのかは、JSX が変換されたあとのコードと、React がコンポーネントを呼ぶ回数を見ると分かります。
検証環境は Ubuntu 24.04、Node.js 22.23.4、React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0、Chromium 153 です。esbuild でまとめた開発用のビルドを Playwright で Chromium に読み込ませ、ボタンを押したときの回数を数えました。本番用のビルド、Firefox と Safari での動作は試していません。

上の画面では、保存ボタンには一度も触れていないのに、handleSave が呼ばれた回数は開いた直後に1、+1 を押すたびに2、3、4と増えています。最後に保存ボタンを押しても、4のままです。押したときに動いてほしい関数が、押したときには動かず、関係のないボタンを押したときに動いています。
JSX は、関数の呼び出しに変換される
次のコンポーネントは、onClick に handleSave() を渡した間違った書き方と、handleSave を渡した正しい書き方です。
export function WrongSaveButton() {
function handleSave() {
console.log('handleSave が呼ばれた');
}
return <button type="button" onClick={handleSave()}>保存</button>;
}
export function SaveButton() {
function handleSave() {
console.log('handleSave が呼ばれた');
}
return <button type="button" onClick={handleSave}>保存</button>;
}
JSX は、ビルドのときに普通の JavaScript に変換されます。esbuild で変換すると、return の行は次のようになりました。
// WrongSaveButton
return /* @__PURE__ */ jsx("button", { type: "button", onClick: handleSave(), children: "保存" });
// SaveButton
return /* @__PURE__ */ jsx("button", { type: "button", onClick: handleSave, children: "保存" });
<button ...> は jsx("button", { ... }) という関数の呼び出しになり、書いた属性はオブジェクトのキーと値になります。公式ドキュメントにも、JSX は JavaScript に変換され、属性は JavaScript オブジェクトのキーになるとあります。この jsx という関数は、React 17 で導入された新しい JSX の変換(英語)で使われるものです。
ここまで来ると、JavaScript の決まりだけで説明がつきます。{ onClick: handleSave() } というオブジェクトを作るとき、JavaScript は handleSave() をその場で実行し、その戻り値を onClick の値にします。handleSave は何も返さないので、onClick には undefined が入ります。一方の { onClick: handleSave } は関数を実行せず、関数そのものを値にします。
コンポーネントは、ただの関数として呼べる
React のコンポーネントも、普通の JavaScript の関数です。そこで、上の2つを React を通さずに Node.js から直接呼び、戻り値の props.onClick を出してみました。
for (let i = 1; i <= 3; i++) {
console.log(`--- WrongSaveButton() を呼ぶ(${i}回目)`);
const el = WrongSaveButton();
console.log('返ってきた要素の props.onClick:', el.props.onClick);
}
console.log('--- SaveButton() を呼ぶ');
const el = SaveButton();
console.log('返ってきた要素の props.onClick:', el.props.onClick);
--- WrongSaveButton() を呼ぶ(1回目)
handleSave が呼ばれた
返ってきた要素の props.onClick: undefined
--- WrongSaveButton() を呼ぶ(2回目)
handleSave が呼ばれた
返ってきた要素の props.onClick: undefined
--- WrongSaveButton() を呼ぶ(3回目)
handleSave が呼ばれた
返ってきた要素の props.onClick: undefined
--- SaveButton() を呼ぶ
返ってきた要素の props.onClick: [Function: handleSave]
WrongSaveButton() は、呼ぶたびに handleSave を1回実行し、onClick が undefined の要素を返しました。SaveButton() は handleSave を実行せず、onClick に関数そのものを持った要素を返しました。ボタンもクリックもない Node.js の上でも handleSave が動いたことから、この呼び出しはクリックとは関係なく、コンポーネントの関数を呼んだことで起きていると分かります。
描画とは、React がコンポーネントの関数を呼ぶこと
あとは、React がいつコンポーネントの関数を呼ぶかです。公式ドキュメントのレンダーとコミットには、「レンダー」とは React がコンポーネントを呼び出すことだと書かれています。最初に画面に出すときに呼ばれ、そのあとは state が更新されるたびに呼ばれます。
これを確かめるため、+1 で数を増やすボタンの隣に、onClick={handleSave()} と書いた保存ボタンを置きました。冒頭の GIF と同じ書き方です。コンポーネントが呼ばれた回数(レンダーの回数)と、handleSave が呼ばれた回数を、それぞれ window の変数で数えています。
import { useState } from 'react';
declare global { interface Window { saveCalls: number; renders: number } }
window.saveCalls = 0;
window.renders = 0;
export function Counter() {
const [count, setCount] = useState(0);
window.renders++;
function handleSave() {
window.saveCalls++;
console.log(`handleSave が呼ばれた(count は ${count})`);
}
return (
<div>
<p>count: {count}</p>
<button type="button" onClick={() => setCount(count + 1)}>+1</button>
<button type="button" onClick={handleSave()}>保存</button>
</div>
);
}
Chromium で開いて +1 を3回押し、最後に保存ボタンを押したときの回数は、次のとおりです。
| 操作 | レンダーの回数 | handleSave の回数 |
|---|---|---|
| 開いた直後 | 1 | 1 |
| +1 を1回押す | 2 | 2 |
| +1 を2回押す | 3 | 3 |
| +1 を3回押す | 4 | 4 |
| 保存ボタンを押す | 4 | 4 |
レンダーの回数と handleSave の回数は、いつも同じでした。+1 を押すと count の state が変わり、React が Counter をもう一度呼びます。そのたびに jsx("button", { onClick: handleSave() ... }) も実行し直されるので、handleSave も呼ばれます。保存ボタンの onClick は undefined なので、押しても何も起きません。
親の state が変わっても動く
state を持っているのが親のコンポーネントでも、同じことが起きます。公式ドキュメントの同じ節にあるとおり、React は更新されたコンポーネントが返した子のコンポーネントも、続けてレンダーするためです。
function SaveButton() {
function handleSave() {
window.childSaveCalls++;
}
return <button type="button" onClick={handleSave()}>保存</button>;
}
export function Parent() {
const [text, setText] = useState('');
return (
<div>
<input aria-label="メモ" value={text} onChange={(e) => setText(e.target.value)} />
<SaveButton />
</div>
);
}
親の入力欄に「こんにちは」と1文字ずつ5文字入力すると、子の handleSave が呼ばれた回数は、開いた直後の1回から6回に増えました。保存ボタンのコンポーネントの中だけを見ても、原因に気付きにくくなります。
StrictMode では、開いただけで2回呼ばれる
アプリを <StrictMode> で囲むと、開発中はコンポーネントの関数が2回ずつ呼ばれます。StrictMode の公式リファレンスにあるとおり、純粋でないレンダーのバグを見つけるための動きです。
同じ Counter を <StrictMode> で囲んで開くと、コンソールには、押す前から次の2行が出ました。+1 を1回押したあとは、handleSave の回数は4回になりました。開いたときの2回に、更新のときの2回が加わった数です。
handleSave が呼ばれた(count は 0)
handleSave が呼ばれた(count は 0)
React を使わない addEventListener でも同じ間違いが起きる
() を付けるとその場で実行されて戻り値が渡るのは、React に限らない JavaScript の決まりです。React を使わない普通の DOM の書き方で、同じ間違いをしてみました。
<button id="save">保存</button>
<script>
window.calls = 0;
function handleSave() {
window.calls++;
console.log('handleSave が呼ばれた');
}
document.getElementById('save').addEventListener('click', handleSave());
</script>
ページを開いた直後に handleSave が1回呼ばれ、そのあと保存ボタンを2回押しても、回数は1のままでした。エラーは出ませんでした。
違うのは回数です。この <script> はページを開いたときに1回だけ実行されるので、handleSave も1回しか呼ばれません。React では、レンダーのたびにコンポーネントの関数、つまり onClick={...} を書いた行がもう一度実行されます。そのため、「押していないのに、描画のたびに動く」という形で表れます。
関数そのものを渡すと、押したときだけ動く
直し方は、公式ドキュメントのイベントハンドラの追加の注意書きにあるとおり、関数を呼ばずに渡すことです。onClick={handleSave} と書けば、React はこの関数を覚えておき、ボタンが押されたときに呼びます。
<button type="button" onClick={handleSave}>保存</button>
Counter の保存ボタンをこの書き方に変えたところ、開いた直後と +1 を3回押したあとでは handleSave は0回のままで、保存ボタンを押したときに初めて1回呼ばれました。そのときのコンソールには「handleSave が呼ばれた(count は 3)」と出ました。
引数を渡したいときは、onClick={() => handleSave(id)} のようにアロー関数で包んで、関数を渡す形にします。
handleSave の中で state の set 関数を呼んでいると、レンダーのたびに set 関数が呼ばれてループします。useState の公式リファレンスでは、「Too many re-renders」というエラーの多くがこの書き間違いによるものだとしています。
TypeScript で書いていれば、tsc の型チェックで行と位置が分かります。Counter では次のエラーになりました。onClick に関数ではなく、handleSave の戻り値(void)を渡しているという意味です。
src/Counter.tsx(20,29): error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.





