21

I don't understand why my useEffect() React function can't access my Component's state variable. I'm trying to create a log when a user abandons creating a listing in our app and navigates to another page. I'm using the useEffect() return method of replicating the componentWillUnmount() lifecycle method. Can you help?

Code Sample

  let[progress, setProgress] = React.useState(0)

  ... user starts building their listing, causing progress to increment ...

  console.log(`progress outside useEffect: ${progress}`)
  useEffect(() => {
    return () => logAbandonListing()
  }, [])
  const logAbandonListing = () => {
    console.log(`progress inside: ${progress}`)
    if (progress > 0) {
      addToLog(userId)
    }
  }

Expected Behavior

The code would reach addToLog(), causing this behavior to be logged.

Observed Behavior

This is what happens when a user types something into their listing, causing progress to increment, and then leaves the page.

  • The useEffect() method works perfectly, and fires the logAbandonListing() function
  • The first console.log() (above useEffect) logs something greater than 0 for the progress state
  • The second console.log() logs 0 for the progress state, disabling the code to return true for the if statement and reach the addToLog() function.

Environment

  • Local dev environment of an app built with Next.js running in Firefox 76.0.1
  • nextjs v 8.1.0
  • react v 16.8.6

I'd really appreciate some help understanding what's going on here. Thanks.

buzatto
  • 9,704
  • 5
  • 24
  • 33
Davis Jones
  • 1,504
  • 3
  • 17
  • 25

4 Answers4

35

I think it is a typical stale closure problem. And it is hard to understand at first.

With the empty dependency array the useEffect will be run only once. And it will access the state from that one run. So it will have a reference from the logAbandonListing function from this moment. This function will access the state from this moment also. You can resolve the problem more than one way.

One of them is to add the state variable to your dependency.

  useEffect(() => {
    return () => logAbandonListing()
  }, [progress])

Another solution is that you set the state value to a ref. And the reference of the ref is not changing, so you will always see the freshest value.

let[progress, setProgress] = React.useState(0);
const progressRef = React.createRef();
progressRef.current = progress;

...

  const logAbandonListing = () => {
    console.log(`progress inside: ${progressRef.current}`)
    if (progressRef.current > 0) {
      addToLog(userId)
    }
  }

If userId is changing too, then you should add it to the dependency or a reference.

Peter Ambruzs
  • 7,763
  • 3
  • 30
  • 36
  • 1
    *-"With the empty dependency array the useEffect will be run only once."* This made it click in my head, thanks so much! – HardBurn Oct 25 '20 at 19:06
  • I had to use `useRef`. With `createRef` the `.current` seems read-only. – Aximili Mar 16 '21 at 10:27
  • Great answer, the issue is just a problem of `stale closure problem`. React hooks heavily depends on `closure`, besides `useEffect` hook, if your new value based on old value defined by `useState` hook, than make sure you pass a function of form: `setNewState((currentValue) => newValue)` to set new state, **React** will assure you always gets the freshest state value when updating. – chen Jacky Apr 04 '22 at 14:43
  • Thanks a lot, I was using state to store the ref for setInterval, and was getting a similar problem. Never occurred to me to use ref, even when it is blatantly named in the variable. – sayandcode Jun 30 '22 at 07:36
7

To do something in the state's current value in the useEffect's return function where the useEffects dependencies are am empty array [], you could use useReducer. This way you can avoid the stale closure issue and update the state from the useReducer's dispatch function.

Example would be:

import React, { useEffect, useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "set":
      return action.payload;
    case "unMount":
      console.log("This note has been closed: " + state); // This note has been closed: 201
      break;
    default:
      throw new Error();
  }
}

function NoteEditor({ initialNoteId }) {
  const [noteId, dispatch] = useReducer(reducer, initialNoteId);

  useEffect(function logBeforeUnMount() {
    return () => dispatch({ type: "unMount" });
  }, []);


  return <div>{noteId}</div>;
}
export default NoteEditor;

More info on this answer

Prottay Rudra
  • 187
  • 2
  • 9
1

When you return a function from useEffect, it behaves like componentWillUnmount so I think it only runs while cleaning up. You'd need to actually call logAbandonListing like:

useEffect(() => {
  logAbandonListing();
}, []);

So it runs everytime a component re-renders. You can read more about useEffect on https://reactjs.org/docs/hooks-effect.html

It's written excellently.

deadcoder0904
  • 7,232
  • 12
  • 66
  • 163
  • From what I understand, this actually invokes useEffect() in a way that mirrors componentWillMount() in a React class-based component. The return before logAbandonedListing() is key, because that changes the way that the hook behaves to mirror componentWillUnmount(), which is what I'm looking for here. – Davis Jones May 22 '20 at 15:15
  • it mirrors `componentDidMount()` as well as `componentWillMount()` i think & if u return it behaves like `componentWillUnmount()`. saw u found the answer. typical closure. i also thought about that but finally thought `componentWillUnmount()` was an issue. – deadcoder0904 May 22 '20 at 16:20
0

I tried using this sandbox to explain my answer.

Basically you are returning a function from your useEffect Callback. But that returned function is never really invoked so it does no actually execute and thus log the abandon action. If you look at the Code in the sandbox I have added a wrapper Parens and () afterwards to actually cause the method to be invoked leading to console.log executing.

isherwood
  • 58,414
  • 16
  • 114
  • 157
Siddharth Seth
  • 643
  • 5
  • 18