Referencing outdated state in React useEffect hook
Asked Answered
T

7

34

I want to save state to localStorage when a component is unmounted. This used to work in componentWillUnmount.

I tried to do the same with the useEffect hook, but it seems state is not correct in the return function of useEffect.

Why is that? How can I save state without using a class?

Here is a dummy example. When you press close, the result is always 0.

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";

function Example() {
  const [tab, setTab] = useState(0);
  return (
    <div>
      {tab === 0 && <Content onClose={() => setTab(1)} />}
      {tab === 1 && <div>Why is count in console always 0 ?</div>}
    </div>
  );
}

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

  useEffect(() => {
    // TODO: Load state from localStorage on mount

    return () => {
      console.log("count:", count);
    };
  }, []);

  return (
    <div>
      <p>Day: {count}</p>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => props.onClose()}>close</button>
    </div>
  );
}

ReactDOM.render(<Example />, document.querySelector("#app"));

CodeSandbox

Thenceforth answered 5/12, 2018 at 13:40 Comment(0)
D
28

I tried to do the same with the useEffect hook, but it seems state is not correct in the return function of useEffect.

The reason for this is due to closures. A closure is a function's reference to the variables in its scope. Your useEffect callback is only ran once when the component mounts and hence the return callback is referencing the initial count value of 0.

The answers given here are what I would recommend. I would recommend @Jed Richard's answer of passing [count] to useEffect, which has the effect of writing to localStorage only when count changes. This is better than the approach of not passing anything at all writing on every update. Unless you are changing count extremely frequently (every few ms), you wouldn't see a performance issue and it's fine to write to localStorage whenever count changes.

useEffect(() => { ... }, [count]);

If you insist on only writing to localStorage on unmount, there's an ugly hack/solution you can use - refs. Basically you would create a variable that is present throughout the whole lifecycle of the component which you can reference from anywhere within it. However, you would have to manually sync your state with that value and it's extremely troublesome. Refs don't give you the closure issue mentioned above because refs is an object with a current field and multiple calls to useRef will return you the same object. As long as you mutate the .current value, your useEffect can always (only) read the most updated value.

CodeSandbox link

const {useState, useEffect, useRef} = React;

function Example() {
  const [tab, setTab] = useState(0);
  return (
    <div>
      {tab === 0 && <Content onClose={() => setTab(1)} />}
      {tab === 1 && <div>Count in console is not always 0</div>}
    </div>
  );
}

function Content(props) {
  const value = useRef(0);
  const [count, setCount] = useState(value.current);

  useEffect(() => {
    return () => {
      console.log('count:', value.current);
    };
  }, []);

  return (
    <div>
      <p>Day: {count}</p>
      <button
        onClick={() => {
          value.current -= 1;
          setCount(value.current);
        }}
      >
        -1
      </button>
      <button
        onClick={() => {
          value.current += 1;
          setCount(value.current);
        }}
      >
        +1
      </button>
      <button onClick={() => props.onClose()}>close</button>
    </div>
  );
}

ReactDOM.render(<Example />, document.querySelector('#app'));
<script src="https://unpkg.com/[email protected]/umd/react.development.js"></script>
<script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script>

<div id="app"></div>
Deodorize answered 5/12, 2018 at 21:46 Comment(0)
A
9

This will work - using React's useRef - but its not pretty:

function Content(props) {
  const [count, setCount] = useState(0);
  const countRef = useRef();

  // set/update countRef just like a regular variable
  countRef.current = count;

  // this effect fires as per a true componentWillUnmount
  useEffect(() => () => {
    console.log("count:", countRef.current);
  }, []);
}

Note the slightly more bearable (in my opinion!) 'function that returns a function' code construct for useEffect.

The issue is that useEffect copies the props and state at composition time and so never re-evaluates them - which doesn't help this use case but then its not what useEffects are really for.

Thanks to @Xitang for the direct assignment to .current for the ref, no need for a useEffect here. sweet!

Accompany answered 25/8, 2019 at 8:32 Comment(6)
doesn't this declare them to the global scope, whereas const/let/var would scope them to the block?Willettawillette
@JamesWilson no it doesn't, variables declared inside a function are automatically scoped to that function. If it was you that downvoted my answer, I'll say no more. If you didn't, I'll gladly expand on my response if you want.Accompany
I did not know that - although it seems that not using const/let/var is discouraged nearly everywhere?Willettawillette
@JamesWilson I'm not sure where I was going with my original answer, but though I was right I was also rather wrong too .. you are right its discouraged, and more to the point it wasn't the key to fixing the OP code. So I've completely changed my posting!Accompany
Great answer. useRef is the way to go. To make it cleaner, we can simply replace the first effect with the line countRef.current = count;, since react runs the computation when state changes and making it into an effect is a bit redundant.Bekah
very interesting @Xitang, thanks for that comment. It makes a lot of sense, I've updated the answer accordingly.Accompany
A
5

Your useEffect callback function is showing the initial count, that is because your useEffect is run only once on the initial render and the callback is stored with the value of count that was present during the iniital render which is zero.

What you would instead do in your case is

 useEffect(() => {
    // TODO: Load state from localStorage on mount
    return () => {
      console.log("count:", count);
    };
  });

In the react docs, you would find a reason on why it is defined like this

When exactly does React clean up an effect? React performs the cleanup when the component unmounts. However, as we learned earlier, effects run for every render and not just once. This is why React also cleans up effects from the previous render before running the effects next time.

Read the react docs on Why Effects Run on Each Update

It does run on each render, to optimise it you can make it to run on count change. But this is the current proposed behavior of useEffect as also mentioned in the documentation and might change in the actual implementation.

 useEffect(() => {
    // TODO: Load state from localStorage on mount
    return () => {
      console.log("count:", count);
    };
  }, [count]);
Alper answered 5/12, 2018 at 13:52 Comment(3)
But then it runs on every change?Thenceforth
It does run on each render, to optimise it you can make it to run on count change. But this is the current proposed behavior of useEffect as also mentioned in the documentation and might change in the actual implementation.Alper
your need to add an empty array as second parameter to useEffect in order to let it run on behalf of componentDidMount and componentDidUnmount onlyFari
H
3

The other answer is correct. And why not pass [count] to your useEffect, and so save to localStorage whenever count changes? There's no real performance penalty calling localStorage like that.

Holler answered 5/12, 2018 at 13:56 Comment(3)
I don't need to load/save on every change. Only when the component is mounted/unmounted. In the example there is only a count, but I do a lot more in the real program.Thenceforth
If you've got a lot going on then create multiple hooks each one focussed on a specific task. I don't think there's any reason why you couldn't have a small hook who's only responsibility was to persist count to localStorage on change.Holler
But what if this is a not an onClick handler on a button, but an onChange handler on an input. That's called more often. Or the cleanup is trying to persist to a server instead of LocalStorage. You probably don't want the effect to run on each change.Caudle
S
2

Instead of manually tracking your state changes like in the accepted answer you can use useEffect to update the ref.

function Content(props) {
  const [count, setCount] = useState(0);
  const currentCountRef = useRef(count);

  // update the ref if the counter changes
  useEffect(() => {
    currentCountRef.current = count;
  }, [count]);

  // use the ref on unmount
  useEffect(
    () => () => {
      console.log("count:", currentCountRef.current);
    },
    []
  );

  return (
    <div>
      <p>Day: {count}</p>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => props.onClose()}>close</button>
    </div>
  );
}
Soothfast answered 10/10, 2019 at 16:3 Comment(0)
A
1

I kept running into this problem as well, and refs do appear to be the only workaround, but this hook/helper function makes it easy and clean to access up-to-date local variables (usually state variables).

The useEffectUnscoped hook implementation:

var useEffectUnscoped = (callback, diffArray = [], deps) => {
  var ref = useRef();
  var depsRef = useRef();

  depsRef.current = deps;

  if (!ref.current || (diffArray.length && !diffArray.every((item, index) => item === ref.current[index]))) {
    callback(depsRef);

    ref.current = {diffArray};
  }
};

How to use useEffectUnscoped:

useEffectUnscoped((depsRef) => {
  depsRef.current.myCallback();
}, [myDiffValue1, myDiffValue2], {
  myCallback: () => console.log(myStateVar)
});

We most frequently use this for keydown events, so we wrote an even cleaner hook for that specific use case that many may find useful.

The useKeyDown hook implementation:

var useKeyDown = (handleKeyDown, options) => {
  useEffectUnscoped((depsRef) => {
    var handleKeyDown = (event) => depsRef.current.handleKeyDown(event);

    document.addEventListener('keydown', handleKeyDown, options);

    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [], {handleKeyDown});
};

How to use useKeyDown:

useKeyDown((event) => {
  if (myStateVar) {
    console.log(event.key);
  }
}, {capture: true}); //optional secondary argument
Asiatic answered 5/7, 2023 at 22:48 Comment(0)
G
0

What's happening is first time useEffect runs, it creating a closure over the value of state you're passing; then if you want to get the actual rather the first one.. you've two options:

  • Having the useEffect with a dependency over count, which will refresh it on each change on that dependency.
  • Use function updater on setCount.

If you do something like:

useEffect(() => {
  return () => {
    setCount((current)=>{ console.log('count:', current); return current; });
  };
}, []);

I am adding this solution just in case someone comes here looking for an issue trying to do an update based on old value into a useEffect without reload.

Govan answered 19/8, 2020 at 14:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.