javascript - Loop inside setInterval does not iterate over the second interation - Stack Overflow

I have a useTypewriter hook that seems to skip the second iteration since the i never logs as 1. Why is

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=".3.1/umd/react.production.min.js"></script>
<script src=".3.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

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/ajax/libs/react/18.3.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Share Improve this question edited Jan 29 at 16:34 Drew Reese 204k18 gold badges246 silver badges274 bronze badges asked Jan 29 at 13:40 user8758206user8758206 2,2017 gold badges27 silver badges75 bronze badges 2
  • 1 Make your life easier: move that function out of your component: there is nothing about it that requires it live there, just make it its own thing, accepting a callback argument, so you can then call it as useEffect(() => runMyTimer((data) => {...}, []). And as an upside, now you can also just test your code properly. Although on a JS timer note, you typically don't want setInterval, but setTimeout that ends by scheduling a new call to itself, since JS timers don't guarantee that they actually run accurately, they only guarantee that they won't run faster than told. – Mike 'Pomax' Kamermans Commented Jan 29 at 15:46
  • 1 It seems from this sandbox that codesandbox.io/p/sandbox/kzcqmv the first setState callback is run in a sync manner, and rest all in async. Hence for first setState callback execution i value is 0, and after that it increases twice before the calback is run again. Why that hapens is not documented anywhere. Might be worth raising in React's github. Also this is react 18 specific behaviour, in react 16 it works fine as none of the calls are async. codesandbox.io/p/sandbox/react-16-auto-controlled-hooks-vk5jf – Tushar Shahi Commented Jan 29 at 19:56
Add a comment  | 

4 Answers 4

Reset to default 3

There's no React.StrictMode component at-play here that would indicate any double-mounting of the components or double-invoking of useEffect hook callbacks. Nothing else overtly out of place, and the logging clearly logs when i is 1. I'm in the same boat as David. Nothing jumps out as being overtly incorrect.

I also have a suggestion for simplification.

Instead of iterating and building up a new string value and storing i and displayText in state, use only i as the state and compute and return the derived display string from the text and i values.

Example:

const useTypewriter = (text, speed = 50) => {
  const [i, setI] = useState(0);
  const timerRef = useRef();

  // Effect to instantiate the interval when `speed` or `text` update.
  useEffect(() => {
    setI(0);
    timerRef.current = setInterval(setI, speed, (i) => i + 1);
    return () => clearInterval(timerRef.current);
  }, [speed, text]);

  // Effect to clear interval when `text` string value is completely consumed.
  useEffect(() => {
    if (i === text.length) {
      clearInterval(timerRef.current);
    }
  }, [i, text]);

  // Compute and return the derived display text value.
  return text.slice(0, i);
};

const { useEffect, useState, useRef } = React;

const useTypewriter = (text, speed = 50) => {
  const [i, setI] = useState(0);
  const timerRef = useRef();

  useEffect(() => {
    setI(0);
    timerRef.current = setInterval(setI, speed, (i) => i + 1);
    return () => clearInterval(timerRef.current);
  }, [speed, text]);

  useEffect(() => {
    if (i === text.length) {
      clearInterval(timerRef.current);
    }
  }, [i, text]);

  return text.slice(0, i);
};

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

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

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

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]);

There something weird with how it is getting rendered, but the problem is not why it is skipping a character. The real problem is that it should be skipping the first character and not the second.

Inside your setInterval callback, you call the state setter setDisplayText which is asynchronous, but you increase the i outside of it, right after calling it. This means that inside the setDisplayText the i will have already been incremented. So the first iteration should start have 1 in there.

I do not know why, the first one is run in sync and only after that it starts acting correctly as async.

Perhaps it is related to https://github/facebook/react/issues/25593 or the sub issues mentioned in there.

A quick solution would be to increase the i inside the setDisplayText so you know it is always in-sync with the rest of the code.

Note: as mentioned in the comments, incrementing the i inside the state setter is a bad practice, as these function should be pure (have no side effects). So take the suggested solution as an explanation/verification of what the actual problem is (as described above). The better solution would be to use the i as a state which is updated in the interval, and the text to show can be derived at render time to be 0 - i characters.

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++);
    });
} else {
    clearInterval(typingInterval);
}

The issue is with the below statement. It unintentionally increments the index referenced in the updater function by 1. This issue happens for all updater function calls except the first one. Therefore it appears in the display as the second character has been skipped always.

...
   i++
...

The below code will fix the issue. It essentially preserves the value before the increment, and uses it as the string index.

...
    const typingInterval = setInterval(() => {
      let j = i; // new statement
      if (i < text.length) {
        setDisplayText((prevText) => {
          return prevText + text.charAt(j); // modified statement
        });
        i++;
      } else {
        clearInterval(typingInterval);
      }
    }, speed);
...

An open point:

As mentioned above, only the first updater function call is unaffected by this issue, rest of all updater function calls are affected, therefore the index referenced there is always incremented by one. However, the reason by which this issue is not affected to the first updater function is still to be understood. May some colleagues can take up this point and help us.

发布者:admin,转转请注明出处:http://www.yc00.com/questions/1745294622a4621065.html

相关推荐

发表回复

评论列表(0条)

  • 暂无评论

联系我们

400-800-8888

在线咨询: QQ交谈

邮件:admin@example.com

工作时间:周一至周五,9:30-18:30,节假日休息

关注微信