4

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.

adambutler
  • 121
  • 2
  • 8

2 Answers2

0

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.

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Thanks for your help @drew-reese. I was trying to create a minimal example of my real use case which is asynchronous. 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 has a dependency on the current state. – adambutler Jun 24 '21 at 18:35
  • 1
    @adambutler Ah, I see, I think you missed the mark a bit then. Can you update your question to include a more representative code example? – Drew Reese Jun 24 '21 at 18:36
  • I've updated my reply and original post to clarify it. Thanks for your help. – adambutler Jun 24 '21 at 18:41
  • @adambutler The use-case is still a bit unclear but I've updated my answer to include asynchronous calls based on your counting example. My guess is that you've written your `doSomethingOnce` and `doSomethingOften` functions to have an outer dependency and this is flagged by the linter. – Drew Reese Jun 24 '21 at 21:26
0

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

Varun Sharma
  • 253
  • 1
  • 3
  • 16