はじめに

前回の記事では、useStateを使ったReactの状態管理について解説しました。本記事では、ユーザーのアクションに応答するための「イベント処理」について詳しく解説します。

Webアプリケーションでは、ボタンのクリック、テキストの入力、フォームの送信など、ユーザーとの対話が不可欠です。Reactでは、これらのインタラクションを「イベントハンドラ」という仕組みで処理します。HTMLのイベント属性とは異なる書き方やルールがあるため、Reactならではの作法を理解することが重要です。

本記事を読むことで、以下のことができるようになります。

  • イベントハンドラの基本的な書き方
  • onClick、onChange、onSubmitの使い方
  • イベントオブジェクトの活用方法
  • 制御コンポーネント(Controlled Components)の実装
  • 実践的なフォーム処理

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • Viteで作成したReactプロジェクト(TypeScript推奨)
  • VS Code(推奨)

前提知識

  • 関数コンポーネントの基本
  • useStateの使い方
  • 基本的なHTML/CSSの知識

Reactのイベント処理の基本

HTMLとReactのイベント処理の違い

HTMLでは、イベント処理を小文字の属性名で記述します。

1
2
<!-- HTMLのイベント処理 -->
<button onclick="handleClick()">クリック</button>

Reactでは、キャメルケース(camelCase)で記述し、関数そのものを渡します。

1
2
// Reactのイベント処理
<button onClick={handleClick}>クリック</button>

主な違いをまとめると以下のようになります。

項目 HTML React
属性名 小文字(onclick) キャメルケース(onClick)
ハンドラの指定 文字列 関数
デフォルト動作の防止 return false e.preventDefault()

SyntheticEvent(合成イベント)

Reactでは、ブラウザのネイティブイベントをラップした「SyntheticEvent(合成イベント)」を使用しています。これにより、すべてのブラウザで一貫したイベント処理が可能になります。

1
2
3
4
5
6
function handleClick(e) {
  // eはSyntheticEventオブジェクト
  console.log(e.type);       // 'click'
  console.log(e.target);     // クリックされた要素
  console.log(e.nativeEvent); // ネイティブのDOMイベント
}

SyntheticEventは、ネイティブイベントと同じインターフェースを持ちますが、ブラウザ間の差異を吸収してくれるため、クロスブラウザ対応を意識する必要がありません。

onClickによるクリックイベント処理

基本的なクリックハンドラ

最もシンプルなクリックイベントの例を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { useState } from 'react';

function ClickCounter() {
  const [count, setCount] = useState(0);
  
  function handleClick() {
    setCount(count + 1);
  }
  
  return (
    <div>
      <p>クリック回数: {count}</p>
      <button onClick={handleClick}>クリック</button>
    </div>
  );
}

export default ClickCounter;

期待される結果

ブラウザで確認すると、以下の動作が確認できます。

  • 「クリック」ボタンをクリックするたびにカウントが1増加
  • 画面上の数値がリアルタイムで更新される

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

Reactでは、イベントハンドラを複数の方法で記述できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useState } from 'react';

function EventHandlerPatterns() {
  const [message, setMessage] = useState('');
  
  // パターン1: 関数宣言
  function handleClick1() {
    setMessage('パターン1: 関数宣言');
  }
  
  // パターン2: アロー関数(変数)
  const handleClick2 = () => {
    setMessage('パターン2: アロー関数');
  };
  
  return (
    <div>
      <p>メッセージ: {message}</p>
      
      {/* パターン1: 関数宣言を渡す */}
      <button onClick={handleClick1}>パターン1</button>
      
      {/* パターン2: 変数を渡す */}
      <button onClick={handleClick2}>パターン2</button>
      
      {/* パターン3: インラインで定義 */}
      <button onClick={() => setMessage('パターン3: インライン')}>
        パターン3
      </button>
    </div>
  );
}

export default EventHandlerPatterns;

それぞれの使い分けは以下のとおりです。

パターン 適したケース
関数宣言 複雑なロジックを含む場合
アロー関数(変数) コンポーネント内で再利用する場合
インライン 単純な処理の場合

よくある間違い: 関数の即時実行

イベントハンドラを設定する際に、関数を即時実行してしまうミスがよくあります。

1
2
3
4
5
6
7
8
// NG: 関数を即時実行してしまう(レンダリング時に実行される)
<button onClick={handleClick()}>クリック</button>

// OK: 関数の参照を渡す
<button onClick={handleClick}>クリック</button>

// OK: 引数を渡したい場合はアロー関数でラップ
<button onClick={() => handleClick('引数')}>クリック</button>

イベントハンドラに引数を渡す

イベントハンドラに追加の引数を渡したい場合は、アロー関数でラップします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { useState } from 'react';

function ButtonList() {
  const [selectedItem, setSelectedItem] = useState('');
  
  const items = ['りんご', 'バナナ', 'オレンジ'];
  
  function handleSelect(item) {
    setSelectedItem(item);
  }
  
  return (
    <div>
      <p>選択中: {selectedItem || '未選択'}</p>
      <div>
        {items.map((item) => (
          <button 
            key={item} 
            onClick={() => handleSelect(item)}
          >
            {item}
          </button>
        ))}
      </div>
    </div>
  );
}

export default ButtonList;

オンラインで試す

クリックイベントのサンプルは、以下のリンクで実際に試すことができます。

onChangeによる入力イベント処理

テキスト入力の基本

フォーム要素の値を管理するには、useStateとonChangeを組み合わせます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState } from 'react';

function TextInput() {
  const [text, setText] = useState('');
  
  function handleChange(e) {
    setText(e.target.value);
  }
  
  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={handleChange}
        placeholder="テキストを入力"
      />
      <p>入力内容: {text}</p>
      <p>文字数: {text.length}</p>
    </div>
  );
}

export default TextInput;

期待される結果

ブラウザで確認すると、以下の動作が確認できます。

  • 入力フィールドに文字を入力すると、リアルタイムで下の表示が更新される
  • 文字数も同時にカウントされる

イベントオブジェクト(e)の中身

onChangeハンドラには、イベントオブジェクトが渡されます。よく使うプロパティを見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function InputWithEventInfo() {
  function handleChange(e) {
    console.log('e.target.value:', e.target.value);   // 入力値
    console.log('e.target.name:', e.target.name);     // input要素のname属性
    console.log('e.target.type:', e.target.type);     // input要素のtype
    console.log('e.target.id:', e.target.id);         // input要素のid
  }
  
  return (
    <input
      id="username"
      name="username"
      type="text"
      onChange={handleChange}
      placeholder="ユーザー名を入力"
    />
  );
}

複数の入力フィールドを1つのハンドラで処理

複数の入力フィールドがある場合、name属性を活用して1つのハンドラで処理できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { useState } from 'react';

function ProfileForm() {
  const [formData, setFormData] = useState({
    firstName: '',
    lastName: '',
    email: '',
  });
  
  function handleChange(e) {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value, // 計算されたプロパティ名
    });
  }
  
  return (
    <div>
      <h2>プロフィール入力</h2>
      
      <div style={{ marginBottom: '12px' }}>
        <label>
          :
          <input
            type="text"
            name="lastName"
            value={formData.lastName}
            onChange={handleChange}
          />
        </label>
      </div>
      
      <div style={{ marginBottom: '12px' }}>
        <label>
          :
          <input
            type="text"
            name="firstName"
            value={formData.firstName}
            onChange={handleChange}
          />
        </label>
      </div>
      
      <div style={{ marginBottom: '12px' }}>
        <label>
          メール:
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
          />
        </label>
      </div>
      
      <div style={{ marginTop: '16px', padding: '12px', background: '#f5f5f5' }}>
        <p>入力内容:</p>
        <p>氏名: {formData.lastName} {formData.firstName}</p>
        <p>メール: {formData.email}</p>
      </div>
    </div>
  );
}

export default ProfileForm;

この方法では、[name]: valueという計算されたプロパティ名(Computed Property Names)を使用しています。これにより、どのフィールドが変更されたかをname属性から判断し、対応するstateのプロパティを更新できます。

制御コンポーネントと非制御コンポーネント

制御コンポーネント(Controlled Components)

Reactでフォーム要素を扱う際の推奨パターンが「制御コンポーネント」です。フォーム要素の値をReactのstateで管理し、入力の変更をonChangeで検知してstateを更新します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');
  
  return (
    <input
      type="text"
      value={value}              // stateの値を表示
      onChange={(e) => setValue(e.target.value)} // stateを更新
    />
  );
}

制御コンポーネントのメリットは以下のとおりです。

  • 入力値をリアルタイムで検証できる
  • 条件に基づいて入力を制限できる
  • フォームの値を外部から変更できる
  • 複数のフォーム要素間で値を同期できる

非制御コンポーネント(Uncontrolled Components)

非制御コンポーネントでは、フォームの値をDOMが直接管理します。値を取得する際にはrefを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);
  
  function handleSubmit(e) {
    e.preventDefault();
    console.log('入力値:', inputRef.current.value);
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="text" 
        ref={inputRef}
        defaultValue="初期値" // valueではなくdefaultValueを使用
      />
      <button type="submit">送信</button>
    </form>
  );
}
項目 制御コンポーネント 非制御コンポーネント
値の管理 React state DOM
値の取得 stateから直接 refを使用
リアルタイム検証 容易 困難
初期値の設定 value defaultValue
推奨度 高(通常はこちら) 特殊なケース向け

入力値のリアルタイム検証

制御コンポーネントを使えば、入力値のリアルタイム検証が簡単に実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { useState } from 'react';

function EmailValidator() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  
  function validateEmail(value) {
    if (!value) {
      return 'メールアドレスを入力してください';
    }
    if (!value.includes('@')) {
      return 'メールアドレスには@が必要です';
    }
    if (!value.includes('.')) {
      return '有効なメールアドレス形式ではありません';
    }
    return '';
  }
  
  function handleChange(e) {
    const value = e.target.value;
    setEmail(value);
    setError(validateEmail(value));
  }
  
  return (
    <div>
      <label>
        メールアドレス:
        <input
          type="email"
          value={email}
          onChange={handleChange}
          style={{
            borderColor: error ? 'red' : '#ccc',
            padding: '8px',
            width: '250px',
          }}
        />
      </label>
      {error && (
        <p style={{ color: 'red', marginTop: '4px' }}>{error}</p>
      )}
      {!error && email && (
        <p style={{ color: 'green', marginTop: '4px' }}>有効なメールアドレスです</p>
      )}
    </div>
  );
}

export default EmailValidator;

onSubmitによるフォーム送信処理

基本的なフォーム送信

フォームの送信を処理するには、formタグにonSubmitハンドラを設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import { useState } from 'react';

function LoginForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: '',
  });
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  function handleChange(e) {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value,
    });
  }
  
  function handleSubmit(e) {
    e.preventDefault(); // ページのリロードを防止
    
    setIsSubmitting(true);
    
    // 実際のアプリではここでAPIリクエストを送信
    console.log('送信データ:', formData);
    
    // 送信完了をシミュレート
    setTimeout(() => {
      alert('ログイン成功!');
      setIsSubmitting(false);
    }, 1000);
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>ログイン</h2>
      
      <div style={{ marginBottom: '16px' }}>
        <label>
          メールアドレス:
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            required
            style={{ display: 'block', width: '100%', padding: '8px' }}
          />
        </label>
      </div>
      
      <div style={{ marginBottom: '16px' }}>
        <label>
          パスワード:
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            required
            style={{ display: 'block', width: '100%', padding: '8px' }}
          />
        </label>
      </div>
      
      <button 
        type="submit" 
        disabled={isSubmitting}
        style={{ padding: '8px 24px' }}
      >
        {isSubmitting ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  );
}

export default LoginForm;

e.preventDefault()の重要性

HTMLのフォームは、送信時にデフォルトでページをリロードします。Reactではこの動作を防ぐためにe.preventDefault()を呼び出します。

1
2
3
4
function handleSubmit(e) {
  e.preventDefault(); // これがないとページがリロードされる
  // フォーム送信処理
}

期待される結果

ブラウザで確認すると、以下の動作が確認できます。

  • 入力フィールドに値を入力できる
  • 「ログイン」ボタンをクリックすると、ページがリロードされずにデータが処理される
  • 送信中は「ログイン中…」と表示され、ボタンが無効化される

さまざまなフォーム要素のイベント処理

チェックボックス

チェックボックスでは、e.target.checkedを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { useState } from 'react';

function CheckboxExample() {
  const [isAgreed, setIsAgreed] = useState(false);
  const [interests, setInterests] = useState({
    programming: false,
    design: false,
    marketing: false,
  });
  
  function handleInterestChange(e) {
    const { name, checked } = e.target;
    setInterests({
      ...interests,
      [name]: checked,
    });
  }
  
  return (
    <div>
      <h3>単一チェックボックス</h3>
      <label>
        <input
          type="checkbox"
          checked={isAgreed}
          onChange={(e) => setIsAgreed(e.target.checked)}
        />
        利用規約に同意する
      </label>
      <p>同意状態: {isAgreed ? '同意済み' : '未同意'}</p>
      
      <h3>複数チェックボックス</h3>
      <label>
        <input
          type="checkbox"
          name="programming"
          checked={interests.programming}
          onChange={handleInterestChange}
        />
        プログラミング
      </label>
      <label>
        <input
          type="checkbox"
          name="design"
          checked={interests.design}
          onChange={handleInterestChange}
        />
        デザイン
      </label>
      <label>
        <input
          type="checkbox"
          name="marketing"
          checked={interests.marketing}
          onChange={handleInterestChange}
        />
        マーケティング
      </label>
      <p>
        選択中: 
        {Object.entries(interests)
          .filter(([, value]) => value)
          .map(([key]) => key)
          .join(', ') || 'なし'}
      </p>
    </div>
  );
}

export default CheckboxExample;

ラジオボタン

ラジオボタンは、同じname属性を持つグループ内で1つだけ選択できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { useState } from 'react';

function RadioExample() {
  const [selectedPlan, setSelectedPlan] = useState('basic');
  
  const plans = [
    { id: 'basic', label: 'ベーシック', price: 980 },
    { id: 'standard', label: 'スタンダード', price: 1980 },
    { id: 'premium', label: 'プレミアム', price: 2980 },
  ];
  
  function handleChange(e) {
    setSelectedPlan(e.target.value);
  }
  
  const selectedPlanData = plans.find(plan => plan.id === selectedPlan);
  
  return (
    <div>
      <h3>プランを選択</h3>
      {plans.map((plan) => (
        <label key={plan.id} style={{ display: 'block', marginBottom: '8px' }}>
          <input
            type="radio"
            name="plan"
            value={plan.id}
            checked={selectedPlan === plan.id}
            onChange={handleChange}
          />
          {plan.label} - {plan.price}/
        </label>
      ))}
      <p style={{ marginTop: '16px' }}>
        選択中のプラン: {selectedPlanData?.label}{selectedPlanData?.price}/
      </p>
    </div>
  );
}

export default RadioExample;

セレクトボックス

セレクトボックスも、onChangeで値を管理します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useState } from 'react';

function SelectExample() {
  const [selectedCountry, setSelectedCountry] = useState('');
  
  const countries = [
    { code: '', name: '選択してください' },
    { code: 'jp', name: '日本' },
    { code: 'us', name: 'アメリカ' },
    { code: 'uk', name: 'イギリス' },
    { code: 'de', name: 'ドイツ' },
  ];
  
  return (
    <div>
      <h3>国を選択</h3>
      <select 
        value={selectedCountry} 
        onChange={(e) => setSelectedCountry(e.target.value)}
        style={{ padding: '8px', fontSize: '16px' }}
      >
        {countries.map((country) => (
          <option key={country.code} value={country.code}>
            {country.name}
          </option>
        ))}
      </select>
      <p>選択中: {selectedCountry || '未選択'}</p>
    </div>
  );
}

export default SelectExample;

テキストエリア

テキストエリアも、inputと同様にvalue/onChangeで制御します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState } from 'react';

function TextareaExample() {
  const [comment, setComment] = useState('');
  const maxLength = 200;
  
  return (
    <div>
      <h3>コメント</h3>
      <textarea
        value={comment}
        onChange={(e) => setComment(e.target.value)}
        maxLength={maxLength}
        rows={5}
        style={{ width: '100%', padding: '8px' }}
        placeholder="コメントを入力してください"
      />
      <p style={{ textAlign: 'right', color: comment.length > maxLength * 0.8 ? 'orange' : '#666' }}>
        {comment.length} / {maxLength}
      </p>
    </div>
  );
}

export default TextareaExample;

イベントの伝播(Event Propagation)

イベントバブリング

Reactのイベントは、DOMと同様に「バブリング」します。子要素で発生したイベントは、親要素にも伝播します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function BubblingExample() {
  function handleParentClick() {
    console.log('親がクリックされました');
  }
  
  function handleChildClick() {
    console.log('子がクリックされました');
  }
  
  return (
    <div 
      onClick={handleParentClick}
      style={{ padding: '40px', background: '#e0e0e0' }}
    >
      <p>親要素</p>
      <button onClick={handleChildClick}>
        子要素ボタン
      </button>
    </div>
  );
}

ボタンをクリックすると、以下の順序でログが出力されます。

  1. 「子がクリックされました」
  2. 「親がクリックされました」

e.stopPropagation()でバブリングを止める

イベントの伝播を止めるには、e.stopPropagation()を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function StopPropagationExample() {
  function handleParentClick() {
    console.log('親がクリックされました');
  }
  
  function handleChildClick(e) {
    e.stopPropagation(); // バブリングを止める
    console.log('子がクリックされました');
  }
  
  return (
    <div 
      onClick={handleParentClick}
      style={{ padding: '40px', background: '#e0e0e0' }}
    >
      <p>親要素クリックしても子のイベントは伝播しない</p>
      <button onClick={handleChildClick}>
        子要素ボタン
      </button>
    </div>
  );
}

e.preventDefault()とe.stopPropagation()の違い

メソッド 役割
e.preventDefault() ブラウザのデフォルト動作を防止(フォーム送信、リンク遷移など)
e.stopPropagation() イベントの親要素への伝播を停止
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function LinkExample() {
  function handleClick(e) {
    e.preventDefault();     // リンク先への遷移を防止
    e.stopPropagation();    // 親へのバブリングを防止
    console.log('リンクがクリックされました');
  }
  
  return (
    <a href="https://example.com" onClick={handleClick}>
      クリックしても遷移しないリンク
    </a>
  );
}

実践: お問い合わせフォームの実装

これまで学んだ内容を組み合わせて、実践的なお問い合わせフォームを実装してみましょう。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
import { useState } from 'react';

function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    category: '',
    message: '',
    newsletter: false,
  });
  
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isSubmitted, setIsSubmitted] = useState(false);
  
  const categories = [
    { value: '', label: '選択してください' },
    { value: 'general', label: '一般的なお問い合わせ' },
    { value: 'support', label: '技術サポート' },
    { value: 'feedback', label: 'フィードバック' },
    { value: 'other', label: 'その他' },
  ];
  
  function validate() {
    const newErrors = {};
    
    if (!formData.name.trim()) {
      newErrors.name = 'お名前は必須です';
    }
    
    if (!formData.email.trim()) {
      newErrors.email = 'メールアドレスは必須です';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = '有効なメールアドレスを入力してください';
    }
    
    if (!formData.category) {
      newErrors.category = 'カテゴリを選択してください';
    }
    
    if (!formData.message.trim()) {
      newErrors.message = 'メッセージは必須です';
    } else if (formData.message.length < 10) {
      newErrors.message = 'メッセージは10文字以上で入力してください';
    }
    
    return newErrors;
  }
  
  function handleChange(e) {
    const { name, value, type, checked } = e.target;
    setFormData({
      ...formData,
      [name]: type === 'checkbox' ? checked : value,
    });
    
    // 入力時にエラーをクリア
    if (errors[name]) {
      setErrors({
        ...errors,
        [name]: '',
      });
    }
  }
  
  function handleSubmit(e) {
    e.preventDefault();
    
    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }
    
    setIsSubmitting(true);
    
    // 実際のアプリではここでAPIリクエストを送信
    console.log('送信データ:', formData);
    
    // 送信完了をシミュレート
    setTimeout(() => {
      setIsSubmitting(false);
      setIsSubmitted(true);
    }, 1500);
  }
  
  if (isSubmitted) {
    return (
      <div style={{ textAlign: 'center', padding: '40px' }}>
        <h2>送信完了</h2>
        <p>お問い合わせありがとうございます</p>
        <p>確認メールを {formData.email} に送信しました</p>
        <button onClick={() => {
          setFormData({
            name: '',
            email: '',
            category: '',
            message: '',
            newsletter: false,
          });
          setIsSubmitted(false);
        }}>
          新しいお問い合わせを送信
        </button>
      </div>
    );
  }
  
  const inputStyle = {
    display: 'block',
    width: '100%',
    padding: '10px',
    fontSize: '16px',
    border: '1px solid #ccc',
    borderRadius: '4px',
    boxSizing: 'border-box',
  };
  
  const errorStyle = {
    color: 'red',
    fontSize: '14px',
    marginTop: '4px',
  };
  
  const labelStyle = {
    display: 'block',
    marginBottom: '4px',
    fontWeight: 'bold',
  };
  
  return (
    <form onSubmit={handleSubmit} style={{ maxWidth: '500px', margin: '0 auto' }}>
      <h2>お問い合わせフォーム</h2>
      
      <div style={{ marginBottom: '20px' }}>
        <label style={labelStyle}>
          お名前 <span style={{ color: 'red' }}>*</span>
        </label>
        <input
          type="text"
          name="name"
          value={formData.name}
          onChange={handleChange}
          style={{
            ...inputStyle,
            borderColor: errors.name ? 'red' : '#ccc',
          }}
        />
        {errors.name && <p style={errorStyle}>{errors.name}</p>}
      </div>
      
      <div style={{ marginBottom: '20px' }}>
        <label style={labelStyle}>
          メールアドレス <span style={{ color: 'red' }}>*</span>
        </label>
        <input
          type="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          style={{
            ...inputStyle,
            borderColor: errors.email ? 'red' : '#ccc',
          }}
        />
        {errors.email && <p style={errorStyle}>{errors.email}</p>}
      </div>
      
      <div style={{ marginBottom: '20px' }}>
        <label style={labelStyle}>
          お問い合わせ種別 <span style={{ color: 'red' }}>*</span>
        </label>
        <select
          name="category"
          value={formData.category}
          onChange={handleChange}
          style={{
            ...inputStyle,
            borderColor: errors.category ? 'red' : '#ccc',
          }}
        >
          {categories.map((cat) => (
            <option key={cat.value} value={cat.value}>
              {cat.label}
            </option>
          ))}
        </select>
        {errors.category && <p style={errorStyle}>{errors.category}</p>}
      </div>
      
      <div style={{ marginBottom: '20px' }}>
        <label style={labelStyle}>
          メッセージ <span style={{ color: 'red' }}>*</span>
        </label>
        <textarea
          name="message"
          value={formData.message}
          onChange={handleChange}
          rows={5}
          style={{
            ...inputStyle,
            borderColor: errors.message ? 'red' : '#ccc',
            resize: 'vertical',
          }}
        />
        <div style={{ display: 'flex', justifyContent: 'space-between' }}>
          {errors.message && <p style={errorStyle}>{errors.message}</p>}
          <p style={{ textAlign: 'right', color: '#666', fontSize: '14px' }}>
            {formData.message.length} 文字
          </p>
        </div>
      </div>
      
      <div style={{ marginBottom: '20px' }}>
        <label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
          <input
            type="checkbox"
            name="newsletter"
            checked={formData.newsletter}
            onChange={handleChange}
          />
          ニュースレターを受け取る
        </label>
      </div>
      
      <button
        type="submit"
        disabled={isSubmitting}
        style={{
          width: '100%',
          padding: '12px',
          fontSize: '16px',
          backgroundColor: isSubmitting ? '#ccc' : '#007bff',
          color: 'white',
          border: 'none',
          borderRadius: '4px',
          cursor: isSubmitting ? 'not-allowed' : 'pointer',
        }}
      >
        {isSubmitting ? '送信中...' : '送信する'}
      </button>
    </form>
  );
}

export default ContactForm;

オンラインで試す

このお問い合わせフォームの動作は、以下のリンクで実際に試すことができます。

よくある間違いとトラブルシューティング

問題1: 入力しても値が変わらない

1
2
3
4
5
// NG: onChangeがない
<input type="text" value={text} />

// OK: onChangeで値を更新
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />

制御コンポーネントでは、valueとonChangeは必ずセットで使用します。

問題2: チェックボックスでvalue を使っている

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// NG: valueを使っている
<input 
  type="checkbox" 
  value={isChecked}  // これは間違い
  onChange={(e) => setIsChecked(e.target.value)} 
/>

// OK: checkedを使う
<input 
  type="checkbox" 
  checked={isChecked} 
  onChange={(e) => setIsChecked(e.target.checked)}
/>

問題3: イベントハンドラを即時実行している

1
2
3
4
5
// NG: 即時実行(毎回のレンダリングで実行される)
<button onClick={handleClick()}>クリック</button>

// OK: 関数の参照を渡す
<button onClick={handleClick}>クリック</button>

問題4: 非同期処理でイベントオブジェクトを使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// NG: 非同期処理内でeを使う(プール解放後のアクセス)
function handleChange(e) {
  setTimeout(() => {
    console.log(e.target.value); // 動作しない可能性
  }, 1000);
}

// OK: 値を先に取り出しておく
function handleChange(e) {
  const value = e.target.value;
  setTimeout(() => {
    console.log(value); // 正常に動作
  }, 1000);
}

SyntheticEventはパフォーマンスのためにプールされているため、非同期処理内で使用する場合は事前に必要な値を取り出しておく必要があります。

まとめ

本記事では、Reactのイベント処理について基礎から実践まで解説しました。

学んだこと

  • イベントハンドラの基本: キャメルケースで記述し、関数を渡す
  • onClick: クリックイベントの処理方法
  • onChange: フォーム入力の処理とイベントオブジェクトの活用
  • onSubmit: フォーム送信とe.preventDefault()の重要性
  • 制御コンポーネント: Reactでフォームを扱う推奨パターン
  • イベント伝播: バブリングとstopPropagation()

次のステップ

イベント処理の基本を理解したら、次は複数のコンポーネント間で状態を共有する方法を学びましょう。次の記事では、「状態のリフトアップ」について解説します。

また、より複雑なフォーム処理が必要な場合は、React Hook FormやFormikなどのライブラリの導入も検討してみてください。

参考リンク