イベントとエフェクトの分離

イベントハンドラは、同じ操作を再度実行した場合にのみ再実行されます。イベントハンドラとは異なり、エフェクトは、プロップや状態変数などの読み取る値が前回のレンダリング時の値と異なる場合に再同期されます。場合によっては、両方の動作を組み合わせる必要もあります。つまり、一部の値には応答して再実行されるが、他の値には応答しないエフェクトです。このページでは、その方法について説明します。

学ぶこと

  • イベントハンドラとエフェクトのどちらを選択するか
  • エフェクトがリアクティブである理由、そしてイベントハンドラがリアクティブでない理由
  • エフェクトのコードの一部をリアクティブにしない場合の対処法
  • エフェクトイベントとは何か、そしてエフェクトからエフェクトイベントを抽出する方法
  • エフェクトイベントを使用して、エフェクトから最新の props と state を読み取る方法

イベントハンドラとエフェクトの選択

まず、イベントハンドラとエフェクトの違いを再確認しましょう。

チャットルームコンポーネントを実装していると想像してください。要件は以下のようになります。

  1. コンポーネントは、選択されたチャットルームに自動的に接続する必要があります。
  2. 「送信」ボタンをクリックすると、チャットにメッセージを送信する必要があります。

既にコードを実装済みですが、どこに配置するかわからないとします。イベントハンドラとエフェクトのどちらを使用すべきでしょうか?この質問に答える必要があるときはいつでも、コードを実行する必要がある理由を検討してください。

イベントハンドラは、特定の操作に応じて実行されます

ユーザーの視点から見ると、メッセージの送信は、「送信」ボタンがクリックされたために発生する必要があります。ユーザーは、他の時間や他の理由でメッセージが送信された場合、かなり不満を抱くでしょう。そのため、メッセージの送信はイベントハンドラである必要があります。イベントハンドラを使用すると、特定のインタラクションを処理できます。

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}

イベントハンドラを使用すると、sendMessage(message)が、ユーザーがボタンを押した場合にのみ実行されることが確実になります。

エフェクトは、同期が必要なときに実行されます

コンポーネントをチャットルームに接続し続ける必要もあることを思い出してください。そのコードはどこに配置する必要がありますか?

このコードを実行する理由は、特定のインタラクションではありません。ユーザーがチャットルーム画面にどのように移動したかは関係ありません。ユーザーが画面を見てインタラクトできるようになったので、コンポーネントは選択されたチャットサーバーに接続された状態を維持する必要があります。チャットルームコンポーネントがアプリの最初の画面であり、ユーザーがまったくインタラクションを実行していない場合でも、接続する必要があります。これがエフェクトである理由です。

function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}

このコードを使用すると、ユーザーが実行した特定のインタラクションに関係なく、現在選択されているチャットサーバーに常にアクティブな接続があることが確実になります。ユーザーがアプリを開いただけであろうと、別のルームを選択したであろうと、別の画面に移動して戻ってきたであろうと、エフェクトによって、コンポーネントは現在選択されているルームと同期された状態を維持し必要に応じて再接続します。

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  function handleSendClick() {
    sendMessage(message);
  }

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

リアクティブな値とリアクティブなロジック

直感的には、イベントハンドラは常に「手動で」トリガーされる、例えばボタンをクリックすることによってトリガーされると言えます。一方、エフェクトは「自動的」です。同期を維持するために必要なだけ頻繁に実行および再実行されます。

これについて考えるには、より正確な方法があります。

コンポーネントの本体内で宣言されたプロップ、状態、変数は、リアクティブな値と呼ばれます。この例では、serverUrlはリアクティブな値ではありませんが、roomIdmessageはリアクティブな値です。これらはレンダリングデータフローに関与します。

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');

// ...
}

このようなリアクティブな値は、再レンダリングによって変化する可能性があります。たとえば、ユーザーがmessageを編集したり、ドロップダウンで異なるroomIdを選択したりする場合です。イベントハンドラーとEffectは、変化に異なる方法で対応します。

  • イベントハンドラー内のロジックはリアクティブではありません。 ユーザーが同じインタラクション(例:クリック)を再び実行しない限り、再度実行されません。イベントハンドラーは、リアクティブな値の変化に「反応」することなく、それらの値を読み取ることができます。
  • Effect内のロジックはリアクティブです。 Effectがリアクティブな値を読み取る場合、依存関係として指定する必要があります。 その後、再レンダリングによってその値が変更されると、Reactは新しい値を使用してEffectのロジックを再実行します。

この違いを説明するために、前の例を再検討しましょう。

イベントハンドラー内のロジックはリアクティブではありません

このコード行を見てください。このロジックはリアクティブであるべきでしょうか、それともそうでないべきでしょうか?

// ...
sendMessage(message);
// ...

ユーザーの視点から見ると、messageの変更は、メッセージを送信したいという意味ではありません。ユーザーが入力しているという意味だけです。言い換えれば、メッセージを送信するロジックはリアクティブであってはなりません。リアクティブな値が変化したという理由だけで、再度実行されるべきではありません。そのため、イベントハンドラーに属します。

function handleSendClick() {
sendMessage(message);
}

イベントハンドラーはリアクティブではないため、sendMessage(message)は、ユーザーが「送信」ボタンをクリックしたときだけ実行されます。

Effect内のロジックはリアクティブです

これらの行に戻りましょう。

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...

ユーザーの視点から見ると、roomIdの変更は、異なる部屋に接続したいという意味です。言い換えれば、部屋への接続のロジックはリアクティブであるべきです。これらのコード行がリアクティブな値に「追従」し、その値が異なる場合に再度実行されるようにしたいのです。そのため、Effectに属します。

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);

Effectはリアクティブであるため、createConnection(serverUrl, roomId)connection.connect()は、roomIdの各異なる値に対して実行されます。Effectは、チャット接続を現在選択されている部屋に同期した状態に保ちます。

Effectから非リアクティブなロジックを抽出する

リアクティブなロジックと非リアクティブなロジックを混ぜ合わせたい場合、事態はさらに複雑になります。

たとえば、ユーザーがチャットに接続したときに通知を表示したいとします。適切な色で通知を表示できるように、propsから現在のテーマ(ダークまたはライト)を読み取ります。

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...

しかし、themeはリアクティブな値(再レンダリングの結果として変更される可能性があります)であり、Effectによって読み取られるすべてのリアクティブな値は、その依存関係として宣言する必要があります。 そのため、themeをEffectの依存関係として指定する必要があります。

function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ All dependencies declared
// ...

この例を試して、このユーザーエクスペリエンスの問題点を見つけてみてください。

import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      showNotification('Connected!', theme);
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

roomIdが変更されると、期待どおりにチャットが再接続されます。しかし、themeも依存関係であるため、ダークテーマとライトテーマを切り替えるたびにチャットも再接続されます。これは良くありません!

言い換えれば、この行はEffect(リアクティブ)の内側にあるにもかかわらず、リアクティブであるべきではありません。

// ...
showNotification('Connected!', theme);
// ...

この非リアクティブなロジックを、それを囲むリアクティブなEffectから分離する方法が必要です。

Effectイベントの宣言

開発中

このセクションでは、まだ安定版のReactでリリースされていない実験的なAPIについて説明します。

この非リアクティブなロジックをEffectから抽出するには、useEffectEventと呼ばれる特別なHookを使用します。

import { useEffect, useEffectEvent } from 'react';

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...

ここで、onConnectedEffectイベントと呼ばれます。これはEffectロジックの一部ですが、イベントハンドラーと非常によく似た動作をします。その中のロジックはリアクティブではなく、常にpropsとstateの最新の値を「認識」します。

これで、Effectの内側からonConnected Effectイベントを呼び出すことができます。

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ...

これで問題は解決しました。Effectの依存関係のリストからonConnected削除する必要があったことに注意してください。Effectイベントはリアクティブではないため、依存関係から省略する必要があります。

新しい動作が期待どおりに機能することを確認します。

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification('Connected!', theme);
  });

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.on('connected', () => {
      onConnected();
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

  return <h1>Welcome to the {roomId} room!</h1>
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [isDark, setIsDark] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <label>
        <input
          type="checkbox"
          checked={isDark}
          onChange={e => setIsDark(e.target.checked)}
        />
        Use dark theme
      </label>
      <hr />
      <ChatRoom
        roomId={roomId}
        theme={isDark ? 'dark' : 'light'}
      />
    </>
  );
}

Effectイベントは、イベントハンドラーと非常によく似ていると考えてください。主な違いは、イベントハンドラーはユーザーのインタラクションに応じて実行されるのに対し、EffectイベントはEffectからトリガーされることです。Effectイベントを使用すると、Effectのリアクティブ性と、リアクティブであってはならないコードとの間の「連鎖」を断ち切ることができます。

Effectイベントを使用した最新のpropsとstateの読み取り

開発中

このセクションでは、まだ安定版のReactでリリースされていない実験的なAPIについて説明します。

Effectイベントを使用すると、依存関係リンターを抑止しようとする多くのパターンを修正できます。

たとえば、ページ訪問をログに記録するEffectがあるとします。

function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}

その後、サイトに複数のルートを追加します。すると、`Page`コンポーネントは、現在のパスを含む`url`プロップを受け取ります。`logVisit`呼び出しの一部として`url`を渡したいのですが、依存関係リンターが警告します。

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}

コードの意図を考えましょう。各URLは異なるページを表すため、異なるURLごとに個別のアクセスを記録したいのです。つまり、この`logVisit`呼び出しは、`url`に対してリアクティブであるべきです。そのため、このケースでは依存関係リンターに従って、`url`を依存関係として追加するのが妥当です。

function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}

次に、すべてのページアクセスと共にショッピングカート内のアイテム数を追加したいとします。

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
}

Effect内で`numberOfItems`を使用したので、リンターはそれを依存関係として追加するよう求めてきます。しかし、`logVisit`呼び出しを`numberOfItems`に対してリアクティブにしたくありません。ユーザーがショッピングカートに何かを追加して`numberOfItems`が変化しても、ユーザーがページを再度訪問したという意味ではありません。言い換えれば、「ページの訪問」はある意味「イベント」です。それは特定の時点で発生します。

コードを2つの部分に分割します。

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
onVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}

ここで、`onVisit`はEffectイベントです。その内部のコードはリアクティブではありません。そのため、周囲のコードが変更時に再実行されることを心配することなく、`numberOfItems`(またはその他のリアクティブな値!)を使用できます。

一方、Effect自体はリアクティブのままです。Effect内のコードは`url`プロップを使用するため、異なる`url`を持つたびに再レンダリング後、Effectは再実行されます。これは、`onVisit` Effectイベントを呼び出します。

その結果、`url`の変更ごとに`logVisit`を呼び出し、常に最新の`numberOfItems`を読み取ります。しかし、`numberOfItems`が単独で変化しても、コードの再実行は発生しません。

注記

引数なしで`onVisit()`を呼び出し、その中で`url`を読み取ることができるのではないかと疑問に思うかもしれません。

const onVisit = useEffectEvent(() => {
logVisit(url, numberOfItems);
});

useEffect(() => {
onVisit();
}, [url]);

これは機能しますが、この`url`をEffectイベントに明示的に渡す方が優れています。`url`を引数としてEffectイベントに渡すことで、ユーザーの観点から、異なる`url`を持つページへのアクセスは、別個の「イベント」であることを示しています。`visitedUrl`は発生した「イベント」の一部です。

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
onVisit(url);
}, [url]);

Effectイベントが`visitedUrl`を明示的に「要求」しているので、誤って`url`をEffectの依存関係から削除することはできません。`url`の依存関係を削除した場合(異なるページアクセスが1つとしてカウントされる)、リンターが警告します。`url`に対して`onVisit`をリアクティブにしたいので、内部で`url`を読み取るのではなく(リアクティブではないため)、Effectからそれを渡します。

これは、Effect内に非同期ロジックがある場合に特に重要になります。

const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});

useEffect(() => {
setTimeout(() => {
onVisit(url);
}, 5000); // Delay logging visits
}, [url]);

ここで、`onVisit`内の`url`は、最新の`url`に対応しますが(既に変更されている可能性があります)、`visitedUrl`は、もともとこのEffect(およびこの`onVisit`呼び出し)を実行させた`url`に対応します。

詳細解説

依存関係リンターの警告を抑制しても問題ないでしょうか?

既存のコードベースでは、このようなlintルールの抑制が時々見られます。

function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;

useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Avoid suppressing the linter like this:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}

`useEffectEvent`がReactの安定した機能になった後は、リンターの抑制を決して行わないことをお勧めします。

ルールの抑制の最初の欠点は、Effectがコードに導入した新しいリアクティブな依存関係に「反応」する必要がある場合に、Reactが警告しなくなることです。前の例では、Reactによって促されたため、依存関係に`url`を追加しました。リンターを無効にすると、そのEffectに対する将来の編集に対しては、そのようなリマインダーを受け取らなくなります。これにより、バグが発生します。

これは、リンターの抑制によって発生する分かりにくいバグの例です。この例では、`handleMove`関数は、点がカーソルに追従するかどうかを決定するために、現在の`canMove`状態変数の値を読み取る必要があります。しかし、`handleMove`内では`canMove`は常に`trueです。

その理由が分かりますか?

import { useState, useEffect } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  function handleMove(e) {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  }

  useEffect(() => {
    window.addEventListener('pointermove', handleMove);
    return () => window.removeEventListener('pointermove', handleMove);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

このコードの問題は、依存関係リンターの抑制にあります。抑制を削除すると、このEffectは`handleMove`関数に依存する必要があることが分かります。これは理にかなっています。`handleMove`はコンポーネント本体内で宣言されているため、リアクティブな値です。すべてのリアクティブな値は依存関係として指定する必要があります。そうでないと、時間が経つにつれて古くなる可能性があります!

元のコードの作者は、Effectがリアクティブな値に依存していないとReactに対して「嘘をついて」います([])。これが、canMoveが変更された後(そしてhandleMoveも変更された後)、ReactがEffectを再同期しなかった理由です。ReactがEffectを再同期しなかったため、リスナーとしてアタッチされたhandleMoveは、初回レンダリング時に作成されたhandleMove関数です。初回レンダリング時、canMovetrueだったため、初回レンダリングからのhandleMoveは常にその値を参照します。

リンターを抑制しなければ、古い値の問題は見つかりません。

useEffectEventを使用すると、リンターに対して「嘘をつく」必要がなくなり、コードは期待通りに動作します。

import { useState, useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';

export default function App() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [canMove, setCanMove] = useState(true);

  const onMove = useEffectEvent(e => {
    if (canMove) {
      setPosition({ x: e.clientX, y: e.clientY });
    }
  });

  useEffect(() => {
    window.addEventListener('pointermove', onMove);
    return () => window.removeEventListener('pointermove', onMove);
  }, []);

  return (
    <>
      <label>
        <input type="checkbox"
          checked={canMove}
          onChange={e => setCanMove(e.target.checked)}
        />
        The dot is allowed to move
      </label>
      <hr />
      <div style={{
        position: 'absolute',
        backgroundColor: 'pink',
        borderRadius: '50%',
        opacity: 0.6,
        transform: `translate(${position.x}px, ${position.y}px)`,
        pointerEvents: 'none',
        left: -20,
        top: -20,
        width: 40,
        height: 40,
      }} />
    </>
  );
}

これはuseEffectEvent常に正しい解決策であるという意味ではありません。リアクティブにしたくないコード行にのみ適用する必要があります。上記のサンドボックスでは、EffectのコードをcanMoveに関してリアクティブにする必要はありませんでした。そのため、Effect Eventを抽出することが理にかなっていました。

リンターの抑制に関するその他の正しい代替手段については、「Effect依存関係の削除」をご覧ください。

Effect Eventsの制限事項

開発中

このセクションでは、まだ安定版のReactでリリースされていない実験的なAPIについて説明します。

Effect Eventsの使用方法は非常に限定されています。

  • Effect内からのみ呼び出してください。
  • 他のコンポーネントやフックに渡さないでください。

たとえば、Effect Eventをこのように宣言して渡さないでください。

function Timer() {
const [count, setCount] = useState(0);

const onTick = useEffectEvent(() => {
setCount(count + 1);
});

useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events

return <h1>{count}</h1>
}

function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Need to specify "callback" in dependencies
}

代わりに、常にEffect Eventを、それを使用するEffectの隣に直接宣言してください。

function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}

function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});

useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Good: Only called locally inside an Effect
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
}

Effect Eventsは、Effectコードの非リアクティブな「部分」です。それを使用するEffectの隣に配置する必要があります。

要約

  • イベントハンドラーは、特定のインタラクションに応じて実行されます。
  • Effectは、同期が必要なときにいつでも実行されます。
  • イベントハンドラー内のロジックはリアクティブではありません。
  • Effect内のロジックはリアクティブです。
  • EffectからEffect Eventsに非リアクティブなロジックを移動できます。
  • Effect EventsはEffect内からのみ呼び出してください。
  • Effect Eventsを他のコンポーネントやフックに渡さないでください。

課題 1 4:
更新されない変数を修正する

このTimerコンポーネントは、毎秒増加するcount状態変数を保持しています。増加する値はincrement状態変数に格納されています。increment変数は、プラスとマイナスのボタンで制御できます。

しかし、プラスボタンを何回クリックしても、カウンターは毎秒1ずつしか増加しません。このコードの何が間違っていますか?incrementがEffectのコード内で常に1に等しいのはなぜですか?間違いを見つけて修正してください。

import { useState, useEffect } from 'react';

export default function Timer() {
  const [count, setCount] = useState(0);
  const [increment, setIncrement] = useState(1);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + increment);
    }, 1000);
    return () => {
      clearInterval(id);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <h1>
        Counter: {count}
        <button onClick={() => setCount(0)}>Reset</button>
      </h1>
      <hr />
      <p>
        Every second, increment by:
        <button disabled={increment === 0} onClick={() => {
          setIncrement(i => i - 1);
        }}></button>
        <b>{increment}</b>
        <button onClick={() => {
          setIncrement(i => i + 1);
        }}>+</button>
      </p>
    </>
  );
}