98

I'm playing around with React Hooks and am facing a problem. It shows the wrong state when I'm trying to console log it using a 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 is only 1 card in state (wrong behaviour)

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

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>

I am using React 16.7.0-alpha.0 and Chrome 70.0.3538.110

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

Dave Mackey
  • 4,306
  • 21
  • 78
  • 136
Mark Lano
  • 983
  • 1
  • 7
  • 5

9 Answers9

167

This is a common problem for functional components that use the 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 the 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 the CardsProvider component is rendered.

handleCardClick used in the Card functional component is received as a prop and registered once on component mount with useEffect. It's the same function during the entire component lifespan and refers to stale state that was fresh at the time when the 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 basically a recipe that provides a mutable object that can be passed by reference:

const ref = useRef(0);

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

In this case a component should be re-rendered on a 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 anti-pattern 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 a state updater function that receives fresh state instead of stale state from the enclosing scope:

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

In this case a state is needed for synchronous side effects 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

  return () => {
    // unregister 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 the event listener every time, so a callback always gets fresh state from the enclosing scope:

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

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

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

Built-in event handling

Unless the event listener is registered on document, window or other event targets that are outside of the scope of the 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 the 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 this answer suggested to use mutable state that was 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.*
Dave Mackey
  • 4,306
  • 21
  • 78
  • 136
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • 7
    This issue is also explained in the React docs: https://reactjs.org/docs/hooks-faq.html#why-am-i-seeing-stale-props-or-state-inside-my-function – Lorenzo Polidori Sep 12 '19 at 22:18
  • 2
    `Manual event listener re-registration` - requires de-registration before each re-registration, to eliminate duplicate calls. – vsync Jun 17 '20 at 11:07
  • @vsync It's presumed that listeners are unregistered at some point because a component that doesn't do a cleanup is never wanted. There should already be unegistration code on unmount and yes, it should be executed multiple times in this case. Updated for clarity. – Estus Flask Jun 17 '20 at 11:17
  • Note that you use "const" for your useRef(0) - Should be let otherwise it won't work - at least that was the difference in my case using React Native. – mkkl Oct 14 '22 at 14:41
  • @mkkl Can you explain the case? Refs work a similar way in React. It should be `const` because this variable shouldn't be reassigned. It's ref `value` property that is changed, const/let isn't related to that – Estus Flask Oct 14 '22 at 15:11
47

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.

Moses Gitau
  • 947
  • 8
  • 7
  • 1
    Does this ref value is the updated value ? or the previous value? – Kamal Hossain Oct 29 '21 at 06:29
  • @KamalHossain this method returns a reference for the current state value. You should refactor your states to [state, setState, stateRef] and use this method instead of useState(initialValue) – mohkamfer Nov 11 '21 at 19:17
  • 1
    Why is the useEffect there? ref.current will be 1 value behind the current value on the render that the value changes. I think it is more correct to simply say `ref.current = value` - this is not taking into account the OP's question - just how this hook was formulated – akraines Dec 22 '21 at 15:39
  • This worked for me. In your eventListener just read ref.current – Ian M Jun 19 '22 at 03:53
7

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

  //...
}
ChetPrickles
  • 820
  • 13
  • 8
  • 2
    I think you are missing a bigger picture, what about if you don't want to setState you just want to consume the state to return a specific value or want to map over specific property of the state. Your answer is just a piece of a bigger answer. – ncubica Dec 10 '19 at 19:56
  • @ncubica, absolutely, but the other answers all emphasize refs and effects, and I want to emphasize that you might not need them. I missed this the first time around. – ChetPrickles Dec 13 '19 at 05:17
7

Short answer for me

this WILL NOT not trigger re-render ever time myvar changes.

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

This WILL trigger render -> putting myvar in []

const [myvar, setMyvar] = useState('')
  useEffect(() => {    
    setMyvar('foo')
  }, [myvar]);
Michael Nelles
  • 5,426
  • 8
  • 41
  • 57
  • 2
    No, both **WILL** trigger rerender, it's just that the first `useEffect` will fire once - on components mount, while the second one will fire every time `myvar` updates. – steak_Overcooked Aug 03 '21 at 11:10
3

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.

Paduado
  • 1,281
  • 12
  • 5
1

This way your callback will have updated state values always ;)

 // registers an event listener to component parent
 React.useEffect(() => {

    const parentNode = elementRef.current.parentNode

    parentNode.addEventListener('mouseleave', handleAutoClose)

    return () => {
        parentNode.removeEventListener('mouseleave', handleAutoClose)
    }

}, [handleAutoClose])
Bruno Andrade
  • 386
  • 4
  • 5
1

To build off of Moses Gitau's great answer, if you are developing in Typescript, to resolve type errors make the hook function generic:

function useStateRef<T>(initialValue: T | (() => T)): 
   [T, React.Dispatch<React.SetStateAction<T>>, React.MutableRefObject<T>] {
  const [value, setValue] = React.useState(initialValue);

  const ref = React.useRef(value);

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

  return [value, setValue, ref];
}
rolling_codes
  • 15,174
  • 22
  • 76
  • 112
0

Starting from the answer of @Moses Gitau, I'm using a sligthly different one that doesn't give access to a "delayed" version of the value (which is an issue for me) and is a bit more minimalist:

import { useState, useRef } from 'react';

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

    const ref = useRef(initialValue);

    const setValue = (val) => {
        ref.current = val;
        setValueState(val); // to trigger the refresh
    };

    const getValue = (val) => {
        return ref.current;
    };

    return [getValue , setValue];
}
export default useStateRef;

This is what I'm using

Example of usage :

const [getValue , setValue] = useStateRef(0);

const listener = (event) => {
    setValue(getValue() + 1);
};

useEffect(() => {
    window.addEventListener('keyup', listener);

    return () => {
        window.removeEventListener('keyup', listener);
    };
}, []);

Edit : It now gives getValue and not the reference itself. I find it better to keep things more encapsulated in that case.

FTW
  • 922
  • 1
  • 7
  • 19
-1

after changing the following line in the index.js file the button2 works well:

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

you should not use [] as 2nd argument useEffect unless you want it to run once.

more details: https://reactjs.org/docs/hooks-effect.html

  • 1
    This behaviour is equivalent to componentDidUpdate and on every re-render the eventListener would be removed and then recreated which is unnecessary and would be a performance hit. One can either add a dependency to the cards so that adding and removal of event listener would not be much or use useRef to get the mutable value of the cards. – Ujwal Agrawal Mar 30 '21 at 15:56