Wrong React hooks behaviour with event listener

I'm playing around with React hooks and faced a problem. It shows the wrong state when I'm trying to console log it using button handled by event listener.

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

  1. Click on 'Add card' button 2 times
  2. In first card, click on Button1 and see in console that there are 2 cards in state (correct behaviour)
  3. In first card, click on Button2 (handled by event listener) and see in console that there are only 1 card in state (wrong behaviour)

Why does it show the wrong state? In first card, Button2 should display 2 cards in console. Any ideas?

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

I use React 16.7.0-alpha.0 and Chrome 70.0.3538.110

BTW, if I rewrite the CardsProvider using ?lass, the problem is gone. CodeSandbox using class: https://codesandbox.io/s/w2nn3mq9vl

Answers:

Answer

This is common problem for functional components that use useState hook. The same concerns are applicable to any callback functions where useState state is used, e.g. setTimeout or setInterval timer functions.

Event handlers are treated differently in CardsProvider and Card components.

handleCardClick and handleButtonClick used in CardsProvider functional component are defined in its scope. There are new functions each time it runs, they refer to cards state that was obtained at the moment when they were defined. Event handlers are re-registered each time CardsProvider component is rendered.

handleCardClick used in Card functional component is received as a prop and registered once on component mount with useEffect. It's the same function during entire component lifespan and refers to stale state that was fresh at the time when handleCardClick function was defined the first time. handleButtonClick is received as a prop and re-registered on each Card render, it's a new function each time and refers to fresh state.

Mutable state

A common approach that addresses this problem is to use useRef instead of useState. A ref is a basically a recipe that provides a mutable object that can be passed by reference:

const ref = useRef(0);

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

In case a component should be re-rendered on state update like it's expected from useState, refs aren't applicable.

It's possible to keep state updates and mutable state separately but forceUpdate is considered an antipattern in both class and function components (listed for reference only):

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

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

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

State updater function

One solution is to use state updater function that receives fresh state instead of stale state from enclosing scope:

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

In case a state is needed for synchronous side effect like console.log, a workaround is to return the same state to prevent an update.

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

useEffect(() => {
  // register eventListener once
}, []);

This doesn't work well with asynchronous side effects, notably async functions.

Manual event listener re-registration

Another solution is to re-register event listener every time, so a callback always gets fresh state from enclosing scope:

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

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

Built-in event handling

Unless event listener is registered on document, window or other event targets are outside of the scope of current component, React's own DOM event handling has to be used where possible, this eliminates the need for useEffect:

<button onClick={eventListener} />

In the last case event listener can be additionally memoized with useMemo or useCallback to prevent unnecessary re-renders when it's passed as a prop:

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

Previous edition of the answer suggested to use mutable state that is applicable to initial useState hook implementation in React 16.7.0-alpha version but isn't workable in final React 16.8 implementation. useState currently supports only immutable state.

Answer

A much cleaner way to work around this is to create a hook I call useStateRef

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

  const ref = useRef(value);

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

  return [value, setValue, ref];
}

You can now use the ref as a reference to the state value.

Answer

Short answer for me was that useState has a simple solution for this:

function Example() {
  const [state, setState] = useState(initialState);

  function update(updates) {
    // this might be stale
    setState({...state, ...updates});
    // but you can pass setState a function instead
    setState(currentState => ({...currentState, ...updates}));
  }

  //...
}
Answer

Check the console and you'll get the answer:

React Hook useEffect has a missing dependency: 'props.handleCardClick'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)

Just add props.handleCardClick to the array of dependencies and it will work correctly.

Answer

Short answer for me

this WILL NOT not trigger rerender

const [myvar, setMyvar] = useState('')
  useEffect(() => {    
    setMyvar('foo')
  }, []);

This WILL trigger render -> putting myvar in []

const [myvar, setMyvar] = useState('')
  useEffect(() => {    
    setMyvar('foo')
  }, [myvar]);

Tags

Recent Questions

Top Questions

Home Tags Terms of Service Privacy Policy DMCA Contact Us

©2020 All rights reserved.