Novice on the road is always a lot of pits, if you have the same problem, hope to help you a little ✿✿ angry (°▽°) Blue ✿

SetInterval not updated?

A small need to send mobile captcha, 60s countdown. I wrote this (simplified version, irrelevant business code removed), found count60-59-59…. It’s 59. It’s always 59. At that time I :(knowknowl ‘゚ dare゚ ´)!! Why not update!! ??

export default() = > {const [count, setCount] = useState(60);

  const useCountDown = () = > {
    setInterval(() = > {
      if (count === 0) {
        // Clear the timer
      }
      setCount(count - 1);
    }, 1000);
  };
  return (
    <>
      <span>{count}</span>
      <button onClick={useCountDown}></button>
    </>
  );
};
Copy the code

To explore the reasons for

Step1 get out of the mistake

I have been using Vue2. X for two years. Recently, I learned and sold React17+ on the spot in the project, and used the writing method of functional component +hook. (digression, Vue or true love, into the erroneous zone themselves to blame don’t blame the Vue, (づ  ̄  ̄ 3) づ)

Let’s start with a simple example

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={()= > setCount(count + 1)}>click me</button>
    </div>
  );
}
Copy the code

I thought

The Counter function executes once, and React stores the count variable and turns on the count listener. React actively touches the DOM node (the return part) to update when count changes. (1) The function is only executed once, and the DOM node is rendered multiple times. (2) There is a listening mode

The truth comes out (● — ●)

Actually, count is just a number. It’s not some magical “data binding,” “observer,” “agent,” or any other “magic.” As follows:

const count = 42;
/ *... * /
<p>You clicked {count} times</p>;
/ *... * /
Copy the code

The first time our component is rendered, count is initialized to 0. Each time we call setCount(x), React re-executes the Counter function (that is, re-calls the Counter component). Each time the value of count is a constant returned by useState. As follows:

// First render
function Counter() {
  const count = 0; / / useState () returns
  / *... * /
  <p>You clicked {count} times</p>;
  / *... * /
}

// After clicking once, the whole function runs again
function Counter() {
  const count = 1; / / useState () returns
  / *... * /
  <p>You clicked {count} times</p>;
  / *... * /
}

// Click again to run the whole function again
function Counter() {
  const count = 2; / / useState () returns
  / *... * /
  <p>You clicked {count} times</p>;
  / *... * /
}
Copy the code

(1) When you first render and setCount, the Counter function will be re-executed. ② There is no observation count. P “You clicked {count} times” /p” Count is a constant inside the function each time and is returned by useState. Count does not change over time, each rendering of the component “sees” its own count value, and any variables in the component are isolated between renderings.

What did React do in the process? The “switch” that triggers Counter to re-execute is setCount. When we call setCount, React returns the component again with a different count value via useState. React then updates the DOM to match our latest render output.

In step2, let’s think about it in a different way

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

  function delayAlertCount() {
    setTimeout(() = > {
      alert("You clicked on: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={()= > setCount(count + 1)}>Click me</button>
      <button onClick={delayAlertCount}>Show alert</button>
    </div>
  );
}
Copy the code

Click the “Show Alert” button and then click “Click Me”. What will the alert result be? The spoilers

With the above understanding, actually each render can be extracted as follows:

// Render for the first time (click "Show Alert" button, execute callback after 3s)
function Counter() {
  const count = 0;
  / *... * /
  function handleAlertClick() {
    setTimeout(() = > {
      alert("You clicked on: " + count);
    }, 3000);
  }
  / *... * /
}

// +1 after rendering
function Counter() {
  const count = 1;
  / *... * /
  function handleAlertClick() {
    setTimeout(() = > {
      alert("You clicked on: " + count);
    }, 3000);
  }
  / *... * /
}
Copy the code

In the same way, each render will have its own count and handleAlertClick, and each of these versions “remembers” its own count:

// Render for the first time (click "Show Alert" button, execute callback after 3s)
function Counter() {
  / *... * /
  function handleAlertClick() {
    setTimeout(() = > {
      alert("You clicked on: " + 0);
    }, 3000);
  }
  / *... * /
  <button onClick={handleAlertClick} />; // At this point, the count value inside the click event callback is 0
  / *... * /
}

// +1 after rendering
function Counter() {
  / *... * /
  function handleAlertClick() {
    setTimeout(() = > {
      alert("You clicked on: " + 1);
    }, 3000);
  }
  / *... * /
  <button onClick={handleAlertClick} />; // At this point, the count value inside the click event callback is 0
  / *... * /
}
Copy the code

So alert is 0!

Step3 go back to the first example

// First render click the button to start setInterval
export default() = > {const count = 60;

  const useCountDown = () = > {
    setInterval(() = > {
      if (count === 0) {
        // Clear the timer
      }
      setCount(count - 1);
    }, 1000);
  };
  return (
    <>
      <span>{count}</span>
      <button onClick={useCountDown}></button>
    </>
  );
};
Copy the code
// After 1s, setCount(59) is executed. Make a second rendering
export default() = > {const count = 59; // This 59 is not in use!!

  const useCountDown = () = > {
    /* The function is not executed this time, omit this part of the code */
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={useCountDown}></button>
    </>
  );
};
Copy the code
// 2 seconds later, setCount(59) is executed.
// ...
// after ns, setCount(59) is executed again.
Copy the code

How to solve

Use the ref

The count variable in the setInterval callback function (‘ CB ‘) is always the same as the count when useCountDown was called. The count doesn’t change so the timer value doesn’t update. So, let’s start at the point of count and make the count out of the limit of one render (hereafter called a “snapshot”) for the lifetime of the component, so that every second the callback is executed, and the count is read, is different. Get out of the snapshot limit, which lasts the entire life cycle, you must want the REF, so use it! However, a limitation of ref is that if the current value of ref changes, it does not trigger the render again. So we can’t completely ditch useState and just use useRef, use both!

export default() = > {const countSaver = useRef(60);
  const [count, setCount] = useState(countSaver.current);

  const useCountDown = () = > {
    setInterval(() = > {
      // The count is always 60, but countsaver. current changes over time
      if (countSaver.current === 0) {
        // Clear the timer
      }
      // console.log("tick", countSaver.current);
      countSaver.current = countSaver.current - 1;
      setCount(countSaver.current); // Triggers the next render
    }, 1000);
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={useCountDown}>The countdown</button>
    </>
  );
};
Copy the code

We can also do a little bit of change, again with ref, and this time we don’t have to store the count for current, we just store the CB. Each snapshot is re-assigned to current, so that each NEW CB is executed per second. Each NEW CB uses the new count instead of the count in the useCountDown snapshot. The article I referred to, “Declaring setIntervals using React Hooks,” does exactly that

export default() = > {const cbSaver = useRef();
  const [count, setCount] = useState(60);

  cbSaver.current = () = > {
    if (count === 0) {
      // Clear the timer
    }
    setCount(count - 1);
  };

  const useCountDown = () = > {
    setInterval(() = > {
      cbSaver.current();
    }, 1000);
  };

  return (
    <>
      <span>{count}</span>
      <button onClick={useCountDown}>The countdown</button>
    </>
  );
};
Copy the code

The idea of “taking the old and pushing the new”

You can actually solve the problem without using the REF. You’re “sending instructions” to React to update the status. You don’t need to care what the current value is, just change the “old value”. There are two ways to get the old value. One is an updater shaped like setSome(old=>old-1), and the other is to change useState into useReducer.

Let’s start with an updater of the form setCount(count=>count-1).

Advantages: Simplest code, minimal modifications to existing code. Limitations: ① New props cannot be obtained from the updater. ② When the data is complex, it is not easy to manage

For example, when the count is not subtracted by 1 each time, but dynamically changes the specific decrement according to prop. The second point will be mentioned later.

export default() = > {const [count, setCount] = useState(60);
  const updater = (count) = > count - 1;
  if (count === 0) {
    // Clear the timer
  }
  const useCountDown = () = > {
    setInterval(() = > {
      // The count is always 60
      setCount(updater); // The updater function cannot read new props
    }, 1000);
  };
  return (
    <>
      <span>{count}</span>
      <button onClick={useCountDown}></button>
    </>
  );
};
Copy the code

Using useReducer

It seems to me to be an updated version of the updater scheme above. It can get the new props, and it’s more useful when the data is complicated (not yet, but with the logic of clearing the timer).

export default() = > {const reducer = (state) = > state - 1; // The reducer functions can read new props
  const [state, dispatch] = useReducer(reducer, 60);
  if (state === 0) {
    // Clear the timer
  }
  const useCountDown = () = > {
    setInterval(() = > {
      // The value of state is always 60
      dispatch({});
    }, 1000);
  };

  return (
    <>
      <span>{state}</span>
      <button onClick={useCountDown}>The countdown</button>
    </>
  );
};
Copy the code

Added logic

The codes for all three scenarios are here, previewed online

Ref scheme

export default() = > {const cbSaver = useRef();
  const [count, setCount] = useState("");
  const [timer, setTimer] = useState(null);
  if (count === 0) {
    timer && clearInterval(timer);
    timer && setTimer(null);
  }
  cbSaver.current = () = > {
    setCount(count - 1);
  };

  const useCountDown = () = > {
    const t = setInterval(() = > {
      cbSaver.current();
    }, 1000);
    setTimer(t);
    setCount(5);
  };

  return (
    <>
      {timer ? (
        <button>{count}</button>
      ) : (
        <button onClick={useCountDown}>The countdown</button>
      )}
    </>
  );
};
Copy the code

The article “Use React Hooks to declare setIntervals” finally extracts a custom hook, which I originally wanted to extract my own hook. However, I found that, different from the reference article, I need to click the trigger timer, so that hook is not called at the top of the functional component, which violates the principle of hook. In this case, I do not know how to write to extract. Ask the bigwigs for directions

Updater scheme and useReducer scheme

  • Updater solution
export default() = > {const [count, setCount] = useState(5);
  const [timer, setTimer] = useState(null);
  const updater = (count) = > count - 1;
  if (count === 0) {
    timer && clearInterval(timer);
    timer && setTimer(null);
  }
  const useCountDown = () = > {
    const t = setInterval(() = > {
      // The count is always 60
      setCount(updater); // The updater function cannot read new props
    }, 1000);
    setCount(5);
    setTimer(t);
  };
  return (
    <>
      {timer ? (
        <button>{count}</button>
      ) : (
        <button onClick={useCountDown}>The countdown</button>
      )}
    </>
  );
};
Copy the code
  • UseReducer scheme
export default() = > {const reducer = (state, { type, payload }) = > {
    if (type === "deCount") {
      return { ...state, count: state.count - 1 };
    }
    if (type === "createTimer") {
      return { count: 5.timer: payload };
    }
    if (type === "clearTimer") {
      clearInterval(state.timer);
      return { count: 0.timer: null}; }};const [state, dispatch] = useReducer(reducer, {});

  if (state.count === 0) {
    state.timer && dispatch({ type: "clearTimer" });
  }
  const useCountDown = () = > {
    const timer = setInterval(() = > {
      dispatch({ type: "deCount" });
    }, 1000);
    dispatch({ type: "createTimer".payload: timer });
  };

  return (
    <>
      {state.timer ? (
        <button>{state.count}</button>
      ) : (
        <button onClick={useCountDown}>The countdown</button>
      )}
    </>
  );
};
Copy the code

When there is more data management and multiple states are directly interdependent, the logic of useState+ Updater is not as clear and readable as that of useReducer. Although in this example is not very obvious, but there are some scenarios, useReducer advantage is more obvious for example.

reference

UseEffect guide

Declare setInterval using React Hooks

React-Hook usereducer

React-Hook useRef