skip to Main Content

I’ve been trying to create a typewriter effect for my website – currently the animation works but each letter appears twice (i.e I want it to say "Welcome!" but it types out "Wweellccoommee!!". I believe this is an asynchronous issue but I can’t seem to think of a way to fix it.

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


  useEffect(() => {
    let i = 0;
  
    const typeCharacter = () => {
      if (i < text.length) {
        setDisplayText(prevText => prevText + text.charAt(i));
        setTimeout(typeCharacter, speed);
      }
      i++;
    };
  
    typeCharacter(); 
  
  }, [text, speed]);

  return displayText;
};

I started by using setInterval but I was having similar async issues. I’ve also tried using a state to track what character we are on but nothing I’ve tried seems to work.

2

Answers


  1. Changing your typeCharacter function approach to the following fixes the issue. I’m not sure why it happens, but it seems that setTimeout somehow misses it.

    // ...
    const typeCharacter = () => {
      if (i < text.length) {
        setDisplayText(prevText => {
          const newText = `${prevText}${text.charAt(i)}`;
    
          i++;
    
          return newText
        });
    
        setTimeout(typeCharacter, speed);
      }
    };
    // ...
    

    Edit: This got me questioning why it skipped the second character but I couldn’t figure it out, very interesting though.

    But anyhow, I played around with it a bit and I think it’s better without recursion. Also, removing let and just using useState for the index.

    Here’s what I got for the end:

    const useTypewriter = (text, speed = 20) => {
      const [displayText, setDisplayText] = useState("");
      const [index, setIndex] = useState(0);
    
      useEffect(() => {
        const timeoutId = setTimeout(() => {
          if (index === text.length) {
            clearTimeout(timeoutId);
            return;
          }
    
          setDisplayText((prevText) => prevText + text.charAt(index));
          setIndex((prevIndex) => prevIndex + 1);
        }, speed);
    
        return () => {
          clearTimeout(timeoutId);
        };
      }, [text, speed, index]);
    
      return displayText;
    };
    
    Login or Signup to reply.
  2. As the comments pointed out, the original Wweellccoommee!! issue with the doubling up letters was because you were running in dev mode with React.StrictMode wrapping your App. In this situation, React will double-invoke certain functions, including your useEffect() and your state updater function, helping you catch side effects (this helps indicate to you that you’re most likely not doing something correctly, such as mutating state for example).

    When you run this using a production build of React, React.StrictMode is suppressed, so your useEffect() callback isn’t double-invoked anymore. This leaves you with a new issue that you asked about in the comments:

    this now leads to the the second character of the string not being
    displayed i.e "Welcome" becomes "Wlcome"

    The reason this occurs is that React doesn’t necessarily call your state updater function synchronously:

    setDisplayText(prevText => prevText + text.charAt(i));
    

    Here, prevText => prevText + text.charAt(i) isn’t necessarily called immediately, but rather it’s typically be put onto a queue. When React eventually rerenders your component, that’s when it applies the queued state updater functions to compute the new state value for that new render. However, React doesn’t always queue the state updater function, there are times when it will attempt to run it synchronously (eagerly) if it is safe for React to do so, which is what’s happening in your case, and in the below example:

    const { useEffect, useState, useMemo } = React;
    const useTypewriter = (text, speed = 20) => {
      const [displayText, setDisplayText] = useState('');
      useEffect(() => {
        let i = 0;
      
        const typeCharacter = () => {
          if (i < text.length) {
            setDisplayText(prevText => prevText + text.charAt(i));
            setTimeout(typeCharacter, speed);
          }
          i++;
        };
      
        typeCharacter(); 
      
      }, [text, speed]);
    
      return displayText;
    };
    
    const App = () => {
      const text = useTypewriter("Welcome!", 100);
      return <p>{text}</p>;
    }
    
    ReactDOM.createRoot(document.body).render(<App />);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

    In the above example, what occurs is:

    1. Your component mounts, calling useEffect() after the initial render
    2. i is set to 0 and typeCharacter invoked.
    3. setDisplayText(prevText => prevText + text.charAt(i)); runs
    4. As this is the first state update, the state updater function prevText => prevText + text.charAt(i) is ran immediately (synchronously)
    5. prevText + text.charAt(i) is computed, producing the new state of 'W'
    6. setTimeout(typeCharacter, speed); queues typeCharacter to be called after 100ms (speed).
    7. You increment i, now i is 1

    Above, step 4 is the important step because the state setter function is ran synchronously. This is not typically done as the state setter function is queued. As it runs synchronously in this case it has access to the value of i at the time typeCharacter was ran (ie: 0). But let’s now see what happens after your setTimeout() re-executes typeCharacter for the second time, noting that i is currently 1:

    1. setDisplayText(prevText => prevText + text.charAt(i)); runs again. However, this time the state updater function prevText => prevText + text.charAt(i) does not run synchronously. Instead, react queues it and will run it on the next render.
    2. setTimeout() queues your typeCharacter function again
    3. i increments to 2. By this point, your component hasn’t rerendered, so the state updater function hasn’t executed yet. This means that your state updater function is now referring to a value of i that is 2, it’s skipped index 1!
    4. Your component rerenders
    5. Your state updater function executes, with the value of i equalling 2, skipping the second letter e.

    How do you avoid this? By keeping your state updater function pure. That means your state updater function should always return the same output given the same input parameters and should be free of side effects. Your state updater function isn’t pure:

    prevText => prevText + text.charAt(i)
    

    If it’s given a value of "W", it may return "We", or it may return "Wx", etc… depending on what the values of text and i are. Since your function doesn’t guarantee the same output given the same input parameters, it’s not pure, which is a requirement of the state updater function. To make your state updater function pure, you can rewritee your hook a bit so that you can usee use i as your state, and then take a slice() of your input text to derive the displayText:

    const useTypewriter = (text, speed = 20) => {
      const [index, setIndex] = useState(0);
      const displayText = useMemo(() => text.slice(0, index), [index]);
      useEffect(() => {
        if (index >= text.length)
          return;
          
        const timeoutId = setTimeout(() => {
          setIndex(i => i + 1);
        }, speed);
    
        return () => {
          clearTimeout(timeoutId);
        };
      }, [index, text, speed]);
    
      return displayText;
    };
    
    const { useEffect, useState, useMemo } = React;
    const useTypewriter = (text, speed = 20) => {
      const [index, setIndex] = useState(0);
      const displayText = useMemo(() => text.slice(0, index), [index]);
      useEffect(() => {
        if (index >= text.length)
          return;
    
        const timeoutId = setTimeout(() => {
          setIndex(i => i + 1);
        }, speed);
    
        return () => {
          clearTimeout(timeoutId);
        };
      }, [index, text, speed]);
    
      return displayText;
    };
    const App = () => {
      const text = useTypewriter("Welcome!", 100);
      return <p>{text}</p>;
    }
    
    ReactDOM.createRoot(document.body).render(<App />);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>

    Above, i => i + 1 is a pure function. If it’s passed 0, it’ll always output 1, if it’s passed 1, it’ll always output 2. Since it’s pure, it doesn’t rely on external variables (such as i in your updater function), which can potentially change.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search