1. ホーム
  2. javascript

イベントリスナーによるReactフックの間違った動作

2023-10-06 12:40:35

質問

私は Reactフック で遊んでいるのですが、ある問題に直面しています。 イベントリスナーで処理されるボタンを使ってコンソールログを取ろうとすると、間違った状態が表示されるのです。

CodeSandboxです。 https://codesandbox.io/s/lrxw1wr97m

  1. クリックすると 'カードの追加' ボタンを2回クリック
  2. 1枚目のカードで ボタン1 をクリックすると、コンソールに2枚のカードがあることが表示されます (正しい動作)。
  3. 最初のカードで ボタン2 (をクリックすると(イベントリスナーによって処理される)、コンソールで、ステートに1つのカードしかないことがわかります(間違った動作)。

なぜ間違った状態が表示されるのでしょうか?

最初のカードでは Button2 が表示されるはずです。 2 のカードが表示されるはずです。何かアイデアはありますか?

const { useState, useContext, useRef, useEffect } = React;

const CardsContext = React.createContext();

const CardsProvider = props => {
  const [cards, setCards] = useState([]);

  const addCard = () => {
    const id = cards.length;
    setCards([...cards, { id: id, json: {} }]);
  };

  const handleCardClick = id => console.log(cards);
  const handleButtonClick = id => console.log(cards);

  return (
    <CardsContext.Provider
      value={{ cards, addCard, handleCardClick, handleButtonClick }}
    >
      {props.children}
    </CardsContext.Provider>
  );
};

function App() {
  const { cards, addCard, handleCardClick, handleButtonClick } = useContext(
    CardsContext
  );

  return (
    <div className="App">
      <button onClick={addCard}>Add card</button>
      {cards.map((card, index) => (
        <Card
          key={card.id}
          id={card.id}
          handleCardClick={() => handleCardClick(card.id)}
          handleButtonClick={() => handleButtonClick(card.id)}
        />
      ))}
    </div>
  );
}

function Card(props) {
  const ref = useRef();

  useEffect(() => {
    ref.current.addEventListener("click", props.handleCardClick);
    return () => {
      ref.current.removeEventListener("click", props.handleCardClick);
    };
  }, []);

  return (
    <div className="card">
      Card {props.id}
      <div>
        <button onClick={props.handleButtonClick}>Button1</button>
        <button ref={node => (ref.current = node)}>Button2</button>
      </div>
    </div>
  );
}

ReactDOM.render(
  <CardsProvider>
    <App />
  </CardsProvider>,
  document.getElementById("root")
);
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<div id='root'></div>

React 16.7.0-alpha.0、Chrome 70.0.3538.110を使用しています。

ちなみに、CardsProviderをсlassを使って書き直すと、問題は解消されます。 クラスを使ったCodeSandbox https://codesandbox.io/s/w2nn3mq9vl

どのように解決するのですか?

これは、機能コンポーネントで useState フックを使用する機能的なコンポーネントに共通の問題です。同じ懸念は、コールバック関数で useState の状態が使用されている場合、例えば setTimeout または setInterval タイマー機能 .

イベントハンドラの扱いが異なるのは CardsProviderCard コンポーネントで構成されます。

handleCardClickhandleButtonClick で使用される CardsProvider 機能コンポーネントは、そのスコープで定義されます。実行されるたびに新しい関数があり、それらは cards の状態を参照します。イベントハンドラは CardsProvider コンポーネントがレンダリングされるたびに再登録されます。

handleCardClick で使用される Card 機能コンポーネントは prop として受け取られ、コンポーネントマウント時に一旦 useEffect . これはコンポーネントの寿命が尽きるまで同じ関数であり、その時点では新鮮だった古い状態を参照するために handleCardClick 関数が最初に定義された時点では新鮮であった古い状態を参照しています。 handleButtonClick はプロップとして受け取られ、それぞれの Card をレンダリングするたびに再登録されるため、毎回新しい関数となり、新しい状態を参照することになります。

変更可能な状態

この問題に対処するための一般的なアプローチは useRef の代わりに useState . ref は基本的に参照渡し可能なミュータブルオブジェクトを提供するレシピです。

const ref = useRef(0);

function eventListener() {
  ref.current++;
}

この場合、コンポーネントは状態の更新時に再レンダリングされる必要があります。 useState から期待されるように、コンポーネントは状態の更新時に再レンダリングされるべきであり、参照は適用されません。

状態の更新とミュータブルな状態を別々に保持することは可能ですが forceUpdate はクラスと関数の両方のコンポーネントでアンチパターンとされています(参考までに挙げておきます)。

const useForceUpdate = () => {
  const [, setState] = useState();
  return () => setState({});
}

const ref = useRef(0);
const forceUpdate = useForceUpdate();

function eventListener() {
  ref.current++;
  forceUpdate();
}

ステート・アップデータ機能

ひとつの解決策として、ステート・アップデータ関数を使用する方法があります。この関数は、周囲のスコープから古いステートの代わりに新しいステートを受け取ります。

function eventListener() {
  // doesn't matter how often the listener is registered
  setState(freshState => freshState + 1);
}

この場合、ステートは以下のような同期的な副作用のために必要です。 console.log のような同期的な副作用のために状態が必要な場合、回避策として、更新を防ぐために同じ状態を返します。

function eventListener() {
  setState(freshState => {
    console.log(freshState);
    return freshState;
  });
}

useEffect(() => {
  // register eventListener once

  return () => {
    // unregister eventListener once
  };
}, []);

これは非同期な副作用ではうまくいきません。特に async 関数などです。

手動イベントリスナー再登録

もう一つの解決策は、イベントリスナーを毎回再登録することです。そうすれば、コールバックは常に周囲のスコープから新鮮な状態を取得できます。

function eventListener() {
  console.log(state);
}

useEffect(() => {
  // register eventListener on each state update

  return () => {
    // unregister eventListener
  };
}, [state]);

組み込みのイベント処理

イベントリスナーを登録しない限り document , window など、現在のコンポーネントのスコープ外にあるイベントターゲットでは、React 自身の DOM イベントハンドリングを可能な限り使用する必要があり、これによって useEffect :

<button onClick={eventListener} />

最後のケースでは、イベントリスナーをさらに useMemo または useCallback を使うことで、propとして渡されたときに不要な再レンダリングを防ぐことができます。

const eventListener = useCallback(() => {
  console.log(state);
}, [state]);

  • この回答の前版では、初期状態に適用可能なミュータブルステートを使用することを提案しました。 useState フックの実装に適用できましたが、React 16.8の最終的な実装では動作しません。 useState は現在immutable stateのみをサポートしています。