概要
onClick や onSubmit のようないわゆるイベントハンドラと呼ばれるプロパティには特定のイベントが発生したときに実行される関数を登録することになります
慣れればどうということはない話なのですが
この関数登録の記述方法をミスするととんでもないことが起こりかねません。
今回実装中に、実際問題そういう類のことを起こしてしまったので
自戒を兼ねて、以下について整理して記そうと思います
- 事故が起きる実装パターン
- なぜその実装パターンで事故が起きるか
- Reactでよくあるイベントハンドラの定義パターン
今回実装中に起こったこと
学習履歴の実装アプリを実装しており
以下はSupabaseのPostgresで管理しているログ一覧を
各項目ごとに箇条書きで、自身を削除するためのボタン付きで表示するという
Componentの誤った実装例です。
皆さんはこのコンポーネントが読み込まれて実際にレンダリングされるとき
どのようなことが起きると思いますか?
import { useAtom } from 'jotai';
import { recorder } from '../atoms/logAtom'
export const LogList = ({ supabase }) => {
const [logs, setLogs] = useAtom(recorder);
const deleteLog = async (id, index) => {
const { error } = await supabase
.from('study-record')
.delete()
.eq('id', id)
if (error) {
throw error
}
setLogs(() => {
const newLogs = [...logs]
newLogs.splice(index, 1)
return newLogs
})
}
return (
<ul>
{logs.map((log, index) => (
<li key={log.id} data-index={index}>
<span style={styles.span}>{log.title}: {log.time}時間</span>
<button onClick={deleteLog(log.id, index)}>削除</button>
</li>
))}
</ul>
)
}
答えは
logsというStateに格納されているすべての学習履歴が画面からもDBからも消える
です
恐ろしいことこの上ないですね・・・。
なぜこのようなことが起きるか
問題は以下の箇所なのですが
<button onClick={deleteLog(log.id, index)}>削除</button>
この書き方は「deleteLog(log.id, index)の実行結果を、onClick発生時の関数として登録する」ということになってしまい、buttonが読み込まれた瞬間にdeleteLog()が実行されてしまうという挙動になるのです
結果は先ほど述べたとおりで、今回の場合そもそもbuttonが消されるのでonClick発生時の挙動は確かめようがないのですが、仮に参照系や更新系の関数を登録してbuttonが残った場合であっても、押しても何も反応が起きない無用のbuttonがレンダリングされているだけになります
正解の書き方
では、どう書けばよかったかということなのですが
以下のようにアロー関数内に呼びたい関数を定義してやるというのが正解です
<button onClick={() => { deleteLog(log.id, index) }}>削除</button>
こう書いてやることで、アロー関数そのもの(関数の参照)が onClick に渡されてくれます。
そしてbuttonが押された際にアロー関数が呼ばれてさらにその中で定義された関数が呼ばれるという格好ですね。
仮に引数を必要としない関数の場合は以下のように書いてもOKです。
<button onClick={deleteLog}>削除</button>
補足
その1
厳密には私がしでかしたような書き方も、以下のように状況(引数やState等)に応じてイベントハンドラに登録したい関数をreturnしわける・・・という場合は理屈上あり得るので必ずしも100%誤りとはいえません。
ただ、滅多になさそうなパターンですよね
const getEventHandler = (type) => {
if (type === "delete") {
return () => console.log("Delete action executed");
} else if (type === "save") {
return () => console.log("Save action executed");
}
return () => console.log("Default action executed");
};
<button onClick={getEventHandler("delete")}>削除</button>
<button onClick={getEventHandler("save")}>保存</button>
その2
今回はまだJavaScriptで書いていますが
TypeScriptで書く場合はそもそも今回のような悲劇は起こりづらくなりそうです。
小難しい話なので、↑の結論だけ抑えて読み飛ばしてもらってもOKです。
TypeScriptのイベントハンドラは以下のonClickのように多くはMouseEventHandler型として定義されています
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L2265
では、MouseEventHandler型はどんな型かというのを見てみると、EventHandler の派生型として定義されています
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L2130
ではでは、EventHandler がどんな型かというと、TypeScript初心者にとっては恐ろしくわかりづらい構文なのですが、根気強く読み解くと
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L2119
要は以下のような型を想定していることがわかります
(event: E) => void
これは「eventという引数を取り、戻り値がvoidの関数」を意味します ※
これにより誤って「関数を返す」以外の定義の関数を実行をするような定義を
イベントハンドラで実施してしまった場合は以下のようなエラーが出ます
Expected `onClick` listener to be a function, instead got a value of `number` type.
TypeScriptの型安全というメリットはこういう点にも表れるのですね
※ 関数参照で渡される関数が厳密にこの構造でなくてはだめかというとそういうことではありません。まず、引数の条件に関しては、かなりの柔軟性があります。まず、渡される関数が引数を無視しても動作する関数であれば型チェックは通過します。つまり、event引数を関数内で必要としない関数であれば引数定義は必須ではありません。また、2つ以上の引数をとった場合、2つ目以降の型チェックは無視されます。戻り値についてもイベントハンドラで関数参照で渡される関数については無視される挙動になります。
まとめ
イベントハンドラの定義の仕方を注意してねという今回の話は、ブログを書きながら調べていたら公式ドキュメントの「Pitfall」にしっかり書いてありました(わかりやすい動画・書籍は有効活用一方で、やはりエンジニアたるもの公式ドキュメントベースでもしっかり学ばないとだめですね)
https://react.dev/learn/responding-to-events
皆さんは私と同じ過ちをおかされませんように・・・!
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてくださ!
▼▼▼