Search code examples
javascriptreactjssetinterval

Loop inside setInterval does not iterate over the second interation


I have a useTypewriter hook that seems to skip the second iteration since the i never logs as 1. Why is the code not working? The output should be howdy, not hwdy.

Here is the full code:

const { useState, useEffect } = React;

const useTypewriter = (text, speed = 50) => {
    const [displayText, setDisplayText] = useState('');

    useEffect(() => {
        let i = 0;
        const typingInterval = setInterval(() => {
            console.log('text.length: ', text.length);
            if (i < text.length) {
                // problem: i is not 1 on the second iteration
                setDisplayText((prevText) => {
                    console.log('prevText: ', prevText);
                    console.log(i, 'text.charAt(i): ', text.charAt(i));

                    return prevText + text.charAt(i);
                });
                i++;
            } else {
                clearInterval(typingInterval);
            }
        }, speed);

        return () => {
            clearInterval(typingInterval);
        };
    }, [text, speed]);

    return displayText;
};

function App(props) {
    const displayText = useTypewriter('howdy', 200);

  return (
    <div className='App'>
      <h1>{displayText}</h1>
    </div>
  );
}

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


Solution

  • I'm honestly struggling to spot the root cause (I bet once someone points it out it will be more obvious). But it looks like this may be a misuse of mutating a variable instead of relying on state. Specifically the variable i.

    If you put it in state in the hook:

    const [i, setI] = useState(0);
    

    And increment that state:

    setI(i + 1);
    

    And add it as a dependency for the useEffect:

    }, [text, speed, i]);
    

    Then the desired functionality appears to work as expected.

    Taking a step back... By making the effect depend on i we're essentially turning that setInterval into a simpler setTimeout. Which makes more sense to me intuitively because state is updating and components are re-rendering on every interval anyway.

    So we might as well simplify into a setTimeout that gets triggered by the useEffect, as mixing the effect with the interval was getting problematic:

      useEffect(() => {
        const timeout = setTimeout(() => {
          if (i < text.length) {
            setDisplayText((prevText) => prevText + text.charAt(i));
            setI(i + 1);
          }
        }, speed);
    
        return () => {
          clearTimeout(timeout);
        };
      }, [text, speed, i]);