第10回

イベントハンドリング:クリック・入力・キーボードを受け取る

ReactのonClick・onChange・onKeyDownなどのイベント処理を解説。アロー関数で包む理由、イベントオブジェクトの使い方、よくある間違いを実例で学ぶ。

·16分で読める
たける
たける ロスターのボタン、`onClick={setSelectedId(c.id)}` って書いたらページを開いた瞬間にクリックされた状態になっちゃって……。
りこ
りこ `()` がついてるから即実行されてる。`onClick` には「関数」を渡す。「関数の呼び出し」じゃなくて。

onClickには「関数」を渡す

// ❌ これは「関数の呼び出し」──レンダリング時に即実行される
<button onClick={setSelectedId(c.id)}>

// ✅ これは「関数」──クリックされたときだけ実行される
<button onClick={() => setSelectedId(c.id)}>

onClick={setSelectedId(c.id)} と書くと、JSXが評価される(=コンポーネントが描画される)瞬間に setSelectedId(c.id) が実行される。これがstateを更新するので再レンダリングが起き、またJSXが評価されて…と無限ループになる。

() => で包むことで「クリックされたときに実行する関数」になる。

たける
たける 引数がない場合、たとえば `onClick={handleClick}` と書くときは `()=>` いらないですよね?
りこ
りこ そう。関数をそのまま渡せる。`handleClick` はすでに関数だから。`() => handleClick()` と書いても動くけど、冗長になる。

イベントハンドラの書き方パターン

// パターン1:インラインで書く(シンプルな処理)
<button onClick={() => setSelectedId(c.id)}>

// パターン2:外に関数を切り出す(処理が複雑なとき)
const handleSelect = (id: string) => {
  setSelectedId(id)
  // ここに他の処理も書ける
}
<button onClick={() => handleSelect(c.id)}>

// パターン3:引数なし(関数をそのまま渡す)
const handleReset = () => setSelectedId('riko')
<button onClick={handleReset}>

イベントオブジェクト

イベントハンドラにはイベントオブジェクトが渡される。クリックされた座標、押されたキー、入力値などの情報が入っている。

// クリックイベント
<button onClick={(e) => {
  console.log(e.target)        // クリックされた要素
  e.preventDefault()           // デフォルト動作をキャンセル
}}>

// 入力イベント──入力値は e.target.value で取れる
<input onChange={(e) => setName(e.target.value)} />

// キーボードイベント
<input onKeyDown={(e) => {
  if (e.key === 'Enter') handleSubmit()
}} />

検索入力を実装する

登場人物ページに名前で絞り込む検索ボックスを追加してみる。

import { useState } from 'react'

const CharactersPage = () => {
  const [selectedId, setSelectedId] = useState(characters[0].id)
  const [query, setQuery] = useState('')  // 検索クエリのstate

  // queryで絞り込んだキャラクター一覧
  const filtered = characters.filter(c =>
    c.name.includes(query) || c.role.includes(query)
  )

  const selected = characters.find(c => c.id === selectedId)!

  return (
    <div>
      {/* 検索ボックス */}
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="名前・役職で検索"
      />

      {/* ロスター(絞り込み済み) */}
      <div>
        {filtered.map(c => (
          <RosterCard
            key={c.id}
            character={c}
            isSelected={c.id === selectedId}
            onClick={() => setSelectedId(c.id)}
          />
        ))}
        {filtered.length === 0 && <p>該当するキャラクターがいません</p>}
      </div>

      {/* 詳細 */}
      <CharacterCard character={selected} />
    </div>
  )
}
たける
たける 入力するたびに `query` のstateが変わって、`filtered` が再計算されて、ロスターが更新されるってことですよね。リアルタイム検索が数行で書けた。
りこ
りこ 「stateに基づいてUIを計算して表示する」の典型例。jQueryで同じことをやると、入力のたびにDOMを手動で操作しないといけない。

よく使うイベントまとめ

イベント 使いどころ
onClick ボタン・リンク・カードのクリック
onChange input・select・textareaの入力変化
onSubmit フォームの送信(e.preventDefault() とセットで)
onKeyDown キーボード入力(Enterキーで送信など)
onFocus / onBlur 入力欄のフォーカス・フォーカスアウト
onMouseEnter / onMouseLeave ホバー状態の制御
📁 第10回完了時点のファイル構成・完成コード

フォルダ構成

src/
├── components/
│   ├── Header.tsx
│   ├── Footer.tsx
│   ├── CharacterCard.tsx
│   └── RosterCard.tsx      ← 今回追加
├── pages/
│   └── CharactersPage.tsx  ← 検索機能を追加
└── App.tsx

RosterCard.tsx

type RosterCardProps = {
  character: { id: string; name: string; role: string; icon?: string; accentColor: string }
  isSelected: boolean
  onClick: () => void
}

export const RosterCard = ({ character: c, isSelected, onClick }: RosterCardProps) => {
  return (
    <button
      onClick={onClick}
      className={`flex items-center gap-3 p-3 rounded-xl border-2 text-left w-full transition-all ${
        isSelected
          ? 'border-sky-400 bg-sky-50'
          : 'border-slate-100 bg-white hover:border-slate-300'
      }`}
    >
      {c.icon ? (
        <img src={c.icon} alt={c.name} className="w-10 h-10 rounded-full object-cover object-top" />
      ) : (
        <div
          className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0"
          style={{ backgroundColor: c.accentColor }}
        >
          {c.name[0]}
        </div>
      )}
      <div>
        <p className="text-sm font-semibold text-slate-900">{c.name}</p>
        <p className="text-xs text-slate-500">{c.role}</p>
      </div>
    </button>
  )
}

CharactersPage.tsx(検索機能追加)

import { useState } from 'react'
import { CharacterCard } from '../components/CharacterCard'
import { RosterCard } from '../components/RosterCard'

const characters = [/* ...第9回と同じデータ */]

export const CharactersPage = () => {
  const [selectedId, setSelectedId] = useState(characters[0].id)
  const [query, setQuery] = useState('')

  const filtered = characters.filter(c =>
    c.name.includes(query) || c.role.includes(query)
  )

  const selected = characters.find(c => c.id === selectedId)!

  return (
    <div className="max-w-5xl mx-auto px-4 sm:px-8 py-12">
      <h1 className="text-3xl font-bold text-slate-900 mb-8">登場人物</h1>

      <div className="grid grid-cols-1 md:grid-cols-[280px_1fr] gap-8">
        {/* 左ペイン:検索 + ロスター */}
        <div>
          <input
            type="text"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="名前・役職で検索"
            className="w-full px-4 py-2 rounded-xl border border-slate-200 text-sm mb-4 outline-none focus:ring-2 focus:ring-sky-400"
          />
          <div className="flex flex-col gap-2">
            {filtered.map(c => (
              <RosterCard
                key={c.id}
                character={c}
                isSelected={c.id === selectedId}
                onClick={() => setSelectedId(c.id)}
              />
            ))}
            {filtered.length === 0 && (
              <p className="text-sm text-slate-400 text-center py-4">
                該当するキャラクターがいません
              </p>
            )}
          </div>
        </div>

        {/* 右ペイン:詳細 */}
        <CharacterCard character={selected} />
      </div>
    </div>
  )
}

まとめ

  • onClick には関数を渡す。onClick={fn()} は即実行になるので onClick={() => fn()} と書く
  • 引数なしならそのまま渡せる:onClick={handleClick}
  • 入力値は onChange={(e) => setValue(e.target.value)} で取得
  • stateと組み合わせるとリアルタイムな絞り込みが数行で書ける

次の第11回では、条件付きレンダリングを学ぶ。画像がまだないキャラクターのプレースホルダー表示を実装する。