Неверное поведение React перехватывает с прослушивателем событий

Я играю с крючками React и столкнулся с проблемой. Он показывает неправильное состояние, когда я пытаюсь утешить его с помощью кнопки, обработанной прослушивателем событий.

CodeSandbox: https://codesandbox.io/s/lrxw1wr97m

  1. Нажмите кнопку "Добавить карту" 2 раза
  2. На первой карте нажмите на кнопку 1 и увидите в консоли, что в состоянии 2 карты (правильное поведение)
  3. В первой карте нажмите кнопку Button2 (обрабатывается прослушивателем событий) и увидите в консоли, что в состоянии только одна карта (неправильное поведение)

Почему это показывает неправильное состояние? В первой карте Button2 должен отображать 2 карты в консоли. Есть идеи?

import React, { useState, useContext, useRef, useEffect } from "react";
import ReactDOM from "react-dom";
import "./styles.css";

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")
);

Ответ 1

Это общая проблема для функциональных компонентов, которые используют хук useState. То же самое относится к любым функциям обратного вызова, где используется состояние useState, например, setTimeout или setInterval функции таймера.

Обработчики событий обрабатываются по-разному в компонентах CardsProvider и Card.

handleCardClick и handleButtonClick, используемые в функциональном компоненте CardsProvider, определены в его области действия. Каждый раз, когда он запускается, появляются новые функции, они ссылаются на состояние cards, которое было получено в тот момент, когда они были определены. Обработчики событий перерегистрируются каждый раз при визуализации компонента CardsProvider.

handleCardClick, используемый в функциональном компоненте Card, принимается как реквизит и регистрируется один раз при монтировании компонента с помощью useEffect. Это та же самая функция в течение всего срока службы компонента, и она относится к устаревшему состоянию, которое было свежим в то время, когда функция handleCardClick была определена впервые. handleButtonClick принимается как реквизит и перерегистрируется на каждом рендере Card, каждый раз это новая функция, которая ссылается на новое состояние.

Изменчивое состояние

Общий подход, который решает эту проблему, состоит в том, чтобы использовать useRef вместо useState. Ссылка - это в основном рецепт, который предоставляет изменяемый объект, который может быть передан по ссылке:

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() {
  // does not 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
}, []);

Это плохо работает с асинхронными побочными эффектами, особенно с функциями async.

Перерегистрация прослушивателя событий вручную

Другое решение заключается в том, чтобы каждый раз перерегистрировать прослушиватель событий, поэтому обратный вызов всегда получает новое состояние из охватывающей области:

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

useEffect(() => {
  // register eventListener on each state update
}, [state]);

Встроенная обработка событий

Если прослушиватель событий не зарегистрирован в document, window или другие цели событий не входят в область действия текущего компонента, следует по возможности использовать собственную обработку событий DOM React, что устраняет необходимость в useEffect:

<button onClick={eventListener} />

В последнем случае прослушиватель событий может быть дополнительно запомнен с помощью useMemo или useCallback, чтобы предотвратить ненужные повторные рендеринг, когда он передается как реквизит:

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

В предыдущей редакции ответа предлагалось использовать изменяемое состояние, которое применимо к начальной реализации ловушки useState в версии React 16.7.0-alpha, но не работоспособно в окончательной реализации React 16.8. useState в настоящее время поддерживает только неизменяемое состояние.

Ответ 2

Более чистый способ обойти это - создать ловушку, которую я называю useStateRef

function useStateRef(initialValue) {
  const [value, setValue] = useState(initialValue);

  const ref = useRef(value);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return [value, setValue, ref];
}

Теперь вы можете использовать ref как ссылку на значение состояния.