useEffect with react-hooks/exhaustive-deps where callbacks depend on state

Viewed 1087

I've got a hooks problem I've been unable to find an answer for. The closest article being this other post.

Essentially I want to have a function that is invoked once per component lifecycle with hooks. I'd normally use useEffect as such:

useEffect(() => {
  doSomethingOnce()  
  setInterval(doSomethingOften, 1000)
}, [])

I'm trying not to disable eslint rules and get a react-hooks/exhaustive-deps warning.

Changing this code to the following creates an issue of my functions being invoked every time the component is rendered... I don't want this.

useEffect(() => {
  doSomethingOnce()
  setInterval(doSomethingOften, 1000)
}, [doSomethingOnce])

The suggested solution is to wrap my functions in useCallback but what I can't find the answer to is what if doSomethingOnce depends on the state.

Below is the minimal code example of what I am trying to achieve:

import "./styles.css";
import { useEffect, useState, useCallback } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const getRandomNumber = useCallback(
    () => Math.floor(Math.random() * 1000),
    []
  );

  const startCounterWithARandomNumber = useCallback(() => {
    setCount(getRandomNumber());
  }, [setCount, getRandomNumber]);

  const incrementCounter = useCallback(() => {
    setCount(count + 1);
  }, [count, setCount]);

  useEffect(() => {
    startCounterWithARandomNumber();
    setInterval(incrementCounter, 1000);
  }, [startCounterWithARandomNumber, incrementCounter]);

  return (
    <div className="App">
      <h1>Counter {count}</h1>
    </div>
  );
}

As you can see from this demo since incrementCounter depends on count it gets recreated. Which in turn re-invokes my useEffect callback that I only wanted to be called the once. The result is that the startCounterWithARandomNumber and incrementCounter get called many more times than I would expect.

Any help would be much appreciated.

Update

I should have pointed out, this is a minimal example of a real-use case where the events are aysncronous.

In my real code the context is a live transcription app. I initially make a fetch call to GET /api/all.json to get the entire transcription then poll GET /api/latest.json every second merging the very latest speech to text from with the current state. I tried to emulate this in my minimal example with a seed to start followed by a polled method call that has a dependency on the current state.

2 Answers

I think you have overcomplicated your code considerably.

Solution

If you want the startCounterWithARandomNumber function to run once to set initial state, then just use a state initialization function.

const getRandomNumber = () => Math.floor(Math.random() * 1000);
const [count, setCount] = useState(getRandomNumber);

As for the effect setting up the interval, you will also want to only run this once when mounting. Move the interval callback that "ticks" and increments the count state into the effect callback so it is no longer a dependency. Use a functional state update to correctly update from the previous state and not the initial state. Don't forget to return a cleanup function from the useEffect hook to clear any running interval timers.

useEffect(() => {
  const incrementCounter = () => setCount((c) => c + 1);
  const timer = setInterval(incrementCounter, 1000);
  return () => clearInterval(timer);
}, []);

Demo

Edit useeffect-with-react-hooks-exhaustive-deps-where-callbacks-depend-on-state

Full code:

import { useEffect, useState } from "react";

export default function App() {
  const getRandomNumber = () => Math.floor(Math.random() * 1000);
  const [count, setCount] = useState(getRandomNumber);

  useEffect(() => {
    const incrementCounter = () => setCount((c) => c + 1);
    const timer = setInterval(incrementCounter, 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="App">
      <h1>Counter {count}</h1>
    </div>
  );
}

Update

I guess it's still a bit unclear what your real code and use case is doing. It seems you've written your doSomethingOnce and doSomethingOften functions in such a way so as to still have some outer dependency that when used inside an useEffect hook is flagged by the linter.

Based on your counting example here an example that doesn't warn about dependencies.

const getRandomNumber = () => Math.floor(Math.random() * 1000);

const fetch = () =>
  new Promise((resolve) => {
    setTimeout(() => {
      if (Math.random() < 0.1) { // 10% to return new value
        resolve(getRandomNumber());
      }
    }, 3000);
  });

function App() {
  const [count, setCount] = React.useState(0);

  const doSomethingOnce = () => {
    console.log('doSomethingOnce');
    fetch().then((val) => setCount(val));
  };
  
  const doSomethingOften = () => {
    console.log('doSomethingOften');
    fetch().then((val) => {
      console.log('new value, update state')
      setCount(val);
    });
  };

  React.useEffect(() => {
    doSomethingOnce();
    const timer = setInterval(doSomethingOften, 1000);

    return () => clearInterval(timer);
  }, []);

  // This effect is only to update state independently of any state updates from "polling"
  React.useEffect(() => {
    const tick = () => setCount((c) => c + 1);
    const timer = setInterval(tick, 100);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="App">
      <h1>Counter {count}</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(
  <App />,
  rootElement
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

This is sort of a contrived and "tuned" solution though since the "fetch" only resolves when it's returning a new value. In reality if you are likely polling and completely replacing a chunk of state that the component isn't regularly modifying (at least I hope it isn't as this makes merging/synchronizing more difficult). I can improve this answer if there were a better/clearer view of what your code is actually doing.

I dont know the usecase you want to solve so keeping minimal code changes to your code. A very simple solution would be to increment counter as

const incrementCounter = useCallback(() => {
    setCount(prevCount => prevCount+1);
}, [setCount]);

This way the increment counter does not rely on count.

Forked from your sandbox. https://codesandbox.io/s/fragrant-architecture-t58bs

Related