Reactでボタンが反応しない|初心者がハマる落とし穴

Reactでボタンクリックが動作しない原因と解決方法を初心者向けに詳しく解説。よくある間違いから適切なイベントハンドリングまで実践的に紹介

Learning Next 運営
35 分で読めます

みなさん、Reactでボタンを作ったのに「クリックしても何も起こらない」という経験はありませんか?

「HTMLでは動いていたのに、Reactでは動かない」 「コードを見ても何が間違っているのかわからない」

こんな状況になって困ったことはありませんか? React初心者の方にとって、これはとてもよくある悩みなんです。

この記事では、Reactでボタンが反応しない原因と解決方法について、初心者の方がハマりやすい落とし穴を中心に詳しく解説します。

よくある間違いパターンから正しい解決方法まで、具体的なコード例とともにお伝えしますので、ぜひ参考にしてください。 最後まで読めば、ボタンの問題を自分で解決できるようになりますよ!

最もよくある間違いパターン

まず、React初心者の方がよく犯すボタンの実装ミスを見てみましょう。

これらを理解するだけで、多くの問題が解決できます。

onClickに関数を直接実行してしまう

最も多い間違いの一つが、関数の参照ではなく実行結果を渡してしまうことです。

// ❌ 間違った例:関数を実行してしまっている
function App() {
  const handleClick = () => {
    console.log('ボタンがクリックされました');
  };
  
  return (
    <div>
      {/* これは間違い:handleClick() と書くと即座に実行される */}
      <button onClick={handleClick()}>クリック</button>
    </div>
  );
}

この書き方では、handleClick()に括弧が付いています。 これだと、コンポーネントがレンダリングされる時に関数が実行されてしまいます。

ボタンをクリックした時ではなく、画面に表示された時に実行されるんです。

正しい書き方を見てみましょう。

// ✅ 正しい例:関数の参照を渡す
function App() {
  const handleClick = () => {
    console.log('ボタンがクリックされました');
  };
  
  return (
    <div>
      {/* 正しい:handleClick(括弧なし)で関数を参照 */}
      <button onClick={handleClick}>クリック</button>
    </div>
  );
}

正しい書き方では、handleClickに括弧が付いていません。 これにより、ボタンがクリックされた時に関数が実行されます。

括弧があるかないかで、動作が全く変わってしまうんです。

アロー関数の書き方を間違えている

引数を渡したい場合のアロー関数の書き方でも、よく間違いが起こります。

// ❌ 間違った例:アロー関数の書き方が不正
function App() {
  const [count, setCount] = useState(0);
  
  const incrementBy = (amount) => {
    setCount(count + amount);
  };
  
  return (
    <div>
      <p>カウント: {count}</p>
      {/* これは間違い:即座に実行される */}
      <button onClick={incrementBy(1)}>+1</button>
      <button onClick={incrementBy(5)}>+5</button>
    </div>
  );
}

この書き方では、incrementBy(1)のように括弧付きで書いています。 これも先ほどと同じで、レンダリング時に実行されてしまいます。

正しい書き方がこちらです。

// ✅ 正しい例:適切なアロー関数の使用
function App() {
  const [count, setCount] = useState(0);
  
  const incrementBy = (amount) => {
    setCount(prevCount => prevCount + amount);
  };
  
  return (
    <div>
      <p>カウント: {count}</p>
      {/* 正しい:アロー関数で引数を渡す */}
      <button onClick={() => incrementBy(1)}>+1</button>
      <button onClick={() => incrementBy(5)}>+5</button>
      
      {/* 引数がない場合は直接参照でもOK */}
      <button onClick={() => setCount(0)}>リセット</button>
    </div>
  );
}

アロー関数を使う場合は、() => incrementBy(1)のように書きます。 これで、ボタンをクリックした時に関数が実行されます。

また、setCount(prevCount => prevCount + amount)のように、前の値を基に更新する書き方もポイントです。

thisのバインドを忘れている

クラスコンポーネントを使用している場合によくある問題です。

// ❌ 間違った例:this がバインドされていない
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  
  handleIncrement() {
    // this が undefined になってエラー
    this.setState({ count: this.state.count + 1 });
  }
  
  render() {
    return (
      <div>
        <p>カウント: {this.state.count}</p>
        {/* this.handleIncrement を渡しているが、this がバインドされていない */}
        <button onClick={this.handleIncrement}>増加</button>
      </div>
    );
  }
}

この書き方では、this.handleIncrementの中でthisundefinedになってしまいます。 JavaScript のクラスの仕様による問題です。

正しい書き方は2つあります。

// ✅ 正しい例1:コンストラクタでバインド
class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleIncrement = this.handleIncrement.bind(this);
  }
  
  handleIncrement() {
    this.setState({ count: this.state.count + 1 });
  }
  
  render() {
    return (
      <div>
        <p>カウント: {this.state.count}</p>
        <button onClick={this.handleIncrement}>増加</button>
      </div>
    );
  }
}

1つ目は、コンストラクタでbind(this)を使う方法です。 これにより、thisが正しくバインドされます。

// ✅ 正しい例2:アロー関数を使用
class Counter extends React.Component {
  state = { count: 0 };
  
  handleIncrement = () => {
    this.setState({ count: this.state.count + 1 });
  }
  
  render() {
    return (
      <div>
        <p>カウント: {this.state.count}</p>
        <button onClick={this.handleIncrement}>増加</button>
      </div>
    );
  }
}

2つ目は、アロー関数を使う方法です。 アロー関数は自動的にthisをバインドしてくれるので、より簡潔に書けます。

イベントオブジェクトの扱いを間違えている

フォームの送信やイベントの制御で間違いが起こりやすいパターンです。

// ❌ 間違った例:preventDefault の使い方が不正
function ContactForm() {
  const [email, setEmail] = useState('');
  
  const handleSubmit = () => {
    // event オブジェクトを受け取っていない
    event.preventDefault(); // これはエラーになる
    console.log('送信:', email);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="email" 
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">送信</button>
    </form>
  );
}

この書き方では、eventオブジェクトを受け取っていません。 そのため、event.preventDefault()を使おうとするとエラーになります。

正しい書き方がこちらです。

// ✅ 正しい例:イベントオブジェクトを適切に受け取る
function ContactForm() {
  const [email, setEmail] = useState('');
  
  const handleSubmit = (event) => {
    event.preventDefault(); // フォームのデフォルト送信を防ぐ
    console.log('送信:', email);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input 
        type="email" 
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">送信</button>
    </form>
  );
}

正しい書き方では、handleSubmit関数でeventを受け取っています。 これにより、event.preventDefault()でフォームのデフォルト送信を防ぐことができます。

状態管理に関する問題

ボタンが反応しない原因として、状態管理の間違いもよくあります。

状態を直接変更してしまう

// ❌ 間違った例:状態を直接変更している
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'タスク1', completed: false }
  ]);
  
  const toggleTodo = (id) => {
    // 配列を直接変更(React は変更を検知できない)
    const todo = todos.find(t => t.id === id);
    todo.completed = !todo.completed;
    setTodos(todos); // 同じ参照なので再レンダリングされない
  };
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <span>{todo.text}</span>
          {/* ボタンをクリックしても画面が更新されない */}
          <button onClick={() => toggleTodo(todo.id)}>
            {todo.completed ? '完了' : '未完了'}
          </button>
        </li>
      ))}
    </ul>
  );
}

この書き方では、todos配列の中身を直接変更しています。 Reactはオブジェクトの参照をチェックして再レンダリングを判断するため、直接変更では変化を検知できません。

正しい書き方を見てみましょう。

// ✅ 正しい例:イミュータブルな更新
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'タスク1', completed: false }
  ]);
  
  const toggleTodo = (id) => {
    // 新しい配列を作成して状態を更新
    setTodos(todos.map(todo => 
      todo.id === id 
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  };
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <span>{todo.text}</span>
          <button onClick={() => toggleTodo(todo.id)}>
            {todo.completed ? '完了' : '未完了'}
          </button>
        </li>
      ))}
    </ul>
  );
}

正しい書き方では、mapを使って新しい配列を作成しています。 該当するtodoだけ{ ...todo, completed: !todo.completed }で新しいオブジェクトを作成し、他はそのまま返しています。

これにより、Reactが変更を検知して再レンダリングが発生します。

古い状態を参照してしまう

// ❌ 間違った例:古い状態を参照
function Counter() {
  const [count, setCount] = useState(0);
  
  const handleDoubleIncrement = () => {
    // 両方とも現在の count (0) を参照するため、結果は 1 になる
    setCount(count + 1);
    setCount(count + 1);
  };
  
  return (
    <div>
      <p>カウント: {count}</p>
      {/* 期待:2増加、実際:1しか増加しない */}
      <button onClick={handleDoubleIncrement}>+2</button>
    </div>
  );
}

この書き方では、2回setCountを呼んでいますが、どちらも現在のcount値を参照しています。 そのため、期待通りに2増加しません。

正しい書き方がこちらです。

// ✅ 正しい例:関数型更新を使用
function Counter() {
  const [count, setCount] = useState(0);
  
  const handleDoubleIncrement = () => {
    // 前の状態を基に更新
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  };
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={handleDoubleIncrement}>+2</button>
    </div>
  );
}

正しい書き方では、prevCount => prevCount + 1のように関数を渡しています。 これにより、前の状態を基に更新されるので、期待通りに動作します。

非同期処理での状態更新

// ❌ 問題のある例:非同期処理後の状態更新
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  
  const fetchUser = async () => {
    setLoading(true);
    
    try {
      const response = await fetch(`/api/users/${userId}`);
      const userData = await response.json();
      setUser(userData);
      setLoading(false);
    } catch (error) {
      setLoading(false);
      console.error('ユーザー取得エラー:', error);
    }
  };
  
  return (
    <div>
      {loading ? (
        <p>読み込み中...</p>
      ) : (
        <div>
          {user ? (
            <div>
              <h2>{user.name}</h2>
              <p>{user.email}</p>
            </div>
          ) : (
            <p>ユーザーが見つかりません</p>
          )}
          {/* 連続クリックで重複リクエストが発生する可能性 */}
          <button onClick={fetchUser}>ユーザー情報を取得</button>
        </div>
      )}
    </div>
  );
}

この書き方では、ボタンを連続でクリックすると重複したリクエストが発生する可能性があります。 また、エラーハンドリングも不十分です。

改善した書き方を見てみましょう。

// ✅ 改善された例:適切な状態管理
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const fetchUser = async () => {
    // 既に読み込み中の場合は処理しない
    if (loading) return;
    
    setLoading(true);
    setError(null);
    
    try {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) {
        throw new Error('ユーザー情報の取得に失敗しました');
      }
      
      const userData = await response.json();
      setUser(userData);
    } catch (error) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div>
      {user && (
        <div>
          <h2>{user.name}</h2>
          <p>{user.email}</p>
        </div>
      )}
      
      {error && (
        <p style={{ color: 'red' }}>エラー: {error}</p>
      )}
      
      <button 
        onClick={fetchUser} 
        disabled={loading}
      >
        {loading ? '読み込み中...' : 'ユーザー情報を取得'}
      </button>
    </div>
  );
}

改善した書き方では、以下のポイントが追加されています。

  • if (loading) return; で重複リクエストを防ぐ
  • error状態でエラーメッセージを表示
  • disabled={loading} でボタンを無効化
  • finallyでローディング状態を確実に解除

これにより、より安全で使いやすいコンポーネントになります。

CSSやスタイリングの問題

ボタンが反応しないように見える原因として、CSSの問題もあります。

pointer-eventsが無効になっている

/* ❌ 問題のあるCSS:クリックイベントが無効 */
.disabled-button {
  pointer-events: none; /* すべてのマウスイベントを無効にする */
  opacity: 0.5;
}

.overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  pointer-events: none; /* 背景クリックを無効にしたつもりが、子要素も無効に */
}

pointer-events: none;を設定すると、すべてのマウスイベントが無効になります。 背景のクリックを無効にしたつもりでも、子要素のボタンまで無効になってしまうんです。

正しい解決方法がこちらです。

// ✅ 正しい解決方法:React で制御
function App() {
  const [isDisabled, setIsDisabled] = useState(false);
  
  return (
    <div>
      <button 
        onClick={() => console.log('クリック')}
        disabled={isDisabled} // HTML の disabled 属性を使用
        className={isDisabled ? 'disabled' : ''}
      >
        {isDisabled ? '無効' : '有効'}
      </button>
      
      <button onClick={() => setIsDisabled(!isDisabled)}>
        {isDisabled ? '有効にする' : '無効にする'}
      </button>
    </div>
  );
}

正しい方法では、HTMLのdisabled属性を使用しています。 これにより、ボタンが無効になった時にクリックイベントが発生しません。

z-indexの問題

/* ❌ 問題:ボタンが他の要素の下に隠れている */
.button {
  position: relative;
  z-index: 1; /* 低い z-index */
}

.overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 10; /* 高い z-index でボタンを覆ってしまう */
}

z-indexの値が不適切だと、ボタンが他の要素の下に隠れてしまいます。 見た目はボタンが表示されているのに、クリックできない状態になります。

正しい解決方法を見てみましょう。

// ✅ 解決方法:適切な z-index 管理
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div 
        className="modal-content" 
        onClick={(e) => e.stopPropagation()} // バブリングを防ぐ
      >
        {children}
        <button className="close-button" onClick={onClose}>
          閉じる
        </button>
      </div>
    </div>
  );
}
/* 適切な z-index 設定 */
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
}

.modal-content {
  position: relative;
  z-index: 1001; /* オーバーレイより高い */
}

.close-button {
  z-index: 1002; /* さらに高い z-index */
}

この例では、階層的にz-indexを設定しています。 また、e.stopPropagation()イベントバブリングを防いでいます。

効率的なデバッグ方法

ボタンが反応しない問題を効率的にデバッグする方法をご紹介します。

console.logでデバッグ

function DebuggingExample() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    console.log('handleClick が呼び出されました'); // 1. 関数が呼ばれるかチェック
    console.log('現在の count:', count); // 2. 現在の状態をチェック
    
    setCount(prevCount => {
      console.log('setCount 実行前:', prevCount); // 3. 更新前の値をチェック
      const newCount = prevCount + 1;
      console.log('setCount 実行後:', newCount); // 4. 更新後の値をチェック
      return newCount;
    });
  };
  
  console.log('コンポーネントがレンダリングされました. count:', count); // 5. レンダリングのタイミングをチェック
  
  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={handleClick}>
        増加
      </button>
    </div>
  );
}

console.logを使って、以下のポイントを確認できます。

  • 関数が呼び出されているか
  • 現在の状態がどうなっているか
  • 状態の更新が正しく行われているか
  • コンポーネントのレンダリングタイミング

React Developer Toolsの活用

// React Developer Tools でチェックすべきポイント
function ComponentsToDebug() {
  const [state, setState] = useState({ count: 0 });
  
  // React Developer Tools で以下を確認:
  // 1. Props が正しく渡されているか
  // 2. State が期待通りに更新されているか
  // 3. イベントハンドラが正しく設定されているか
  
  return (
    <div>
      {/* この要素を選択して Props と State を確認 */}
      <ChildComponent 
        count={state.count} 
        onIncrement={() => setState(prev => ({ count: prev.count + 1 }))}
      />
    </div>
  );
}

function ChildComponent({ count, onIncrement }) {
  // React Developer Tools で Props の値を確認
  console.log('ChildComponent props:', { count, onIncrement });
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={onIncrement}>Increment</button>
    </div>
  );
}

React Developer Toolsを使用すると、以下のことが確認できます。

  • Propsが正しく渡されているか
  • Stateが期待通りに更新されているか
  • コンポーネントの階層構造

段階的なテスト

// 段階的にシンプルなケースから確認
function StepByStepDebugging() {
  // Step 1: 最もシンプルなボタン
  const simpleClick = () => {
    alert('シンプルなクリックが動作します');
  };
  
  // Step 2: console.log での確認
  const logClick = () => {
    console.log('ログクリックが動作します');
  };
  
  // Step 3: 状態なしの処理
  const [message, setMessage] = useState('');
  const messageClick = () => {
    setMessage('メッセージが設定されました');
  };
  
  // Step 4: 複雑な状態管理
  const [complex, setComplex] = useState({ count: 0, items: [] });
  const complexClick = () => {
    setComplex(prev => ({
      count: prev.count + 1,
      items: [...prev.items, `アイテム${prev.count + 1}`]
    }));
  };
  
  return (
    <div>
      <div>
        <button onClick={simpleClick}>Step 1: シンプル</button>
      </div>
      
      <div>
        <button onClick={logClick}>Step 2: ログ</button>
      </div>
      
      <div>
        <button onClick={messageClick}>Step 3: メッセージ</button>
        <p>{message}</p>
      </div>
      
      <div>
        <button onClick={complexClick}>Step 4: 複雑</button>
        <p>カウント: {complex.count}</p>
        <ul>
          {complex.items.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

段階的なテストでは、以下の順序で確認します。

  1. 最もシンプルなボタン(alert
  2. console.logでの確認
  3. 状態なしの処理
  4. 複雑な状態管理

この方法により、どの段階で問題が発生しているかを特定できます。

予防策とベストプラクティス

ボタンが反応しない問題を未然に防ぐためのベストプラクティスをご紹介します。

ESLintルールの活用

// .eslintrc.js で有用なルールを設定
module.exports = {
  rules: {
    // イベントハンドラの命名規則を強制
    'react/jsx-handler-names': ['error', {
      'eventHandlerPrefix': 'handle',
      'eventHandlerPropPrefix': 'on'
    }],
    
    // 使用されていない変数を警告
    'no-unused-vars': 'warn',
    
    // console.log の削除忘れを警告
    'no-console': 'warn'
  }
};

ESLintのルールを設定することで、以下のような問題を予防できます。

  • イベントハンドラの命名規則の統一
  • 使用されていない変数の検出
  • console.logの削除忘れの警告

TypeScriptの活用

// TypeScript でイベントハンドラの型を明確にする
interface ButtonProps {
  onClick: () => void;
  disabled?: boolean;
  children: React.ReactNode;
}

const CustomButton: React.FC<ButtonProps> = ({ 
  onClick, 
  disabled = false, 
  children 
}) => {
  return (
    <button 
      onClick={onClick}
      disabled={disabled}
      type="button" // デフォルトの type を明示
    >
      {children}
    </button>
  );
};

// 使用例
function App() {
  const handleClick = (): void => {
    console.log('ボタンがクリックされました');
  };
  
  return (
    <CustomButton onClick={handleClick}>
      クリック
    </CustomButton>
  );
}

TypeScriptを使用することで、以下のメリットがあります。

  • イベントハンドラの型が明確になる
  • コンパイル時にエラーを検出できる
  • IDEでの補完機能が向上する

テストの作成

// React Testing Library でのボタンテスト
import { render, screen, fireEvent } from '@testing-library/react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p data-testid="count">カウント: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        増加
      </button>
    </div>
  );
}

test('ボタンクリックでカウントが増加する', () => {
  render(<Counter />);
  
  const countElement = screen.getByTestId('count');
  const button = screen.getByText('増加');
  
  expect(countElement).toHaveTextContent('カウント: 0');
  
  fireEvent.click(button);
  
  expect(countElement).toHaveTextContent('カウント: 1');
});

テストを作成することで、以下のことが確認できます。

  • ボタンが正しくクリックされるか
  • 期待通りの状態変更が起こるか
  • 画面表示が正しく更新されるか

まとめ

Reactでボタンが反応しない問題について、様々な原因と解決方法を解説しました。

最も重要なポイント

  • onClick={handleClick()}ではなくonClick={handleClick}を使う
  • 状態を直接変更せず、新しいオブジェクトを作成する
  • イベントオブジェクトを適切に受け取る
  • CSSのpointer-eventsz-indexに注意する

効率的なデバッグ方法

  • console.logで段階的に確認する
  • React Developer Toolsを活用する
  • シンプルなケースから複雑なケースへ段階的にテストする

予防策

  • ESLintルールを設定する
  • TypeScriptを活用する
  • テストを作成する

ボタンが反応しない問題は、React学習の初期段階でよく遭遇する課題です。 でも大丈夫です!適切な知識があれば、必ず解決できます。

この記事で紹介した内容を参考に、確実に動作するボタンを実装してみてください。 最初は戸惑うかもしれませんが、練習すればきっと上達しますよ!

困った時は、この記事を見返して一つずつ確認してみてくださいね。

関連記事