reactjstypescriptalgorithmnext.js

Why the second letter is being ignored when using my Typewriter component?


I'm currently building my portfolio and im making a typewriter effect for some text :

"use client"
import { useEffect, useState } from "react"

type Typewriter = {
  textToWrite: string,
  typeSpeed: number
}

function Typewriter({textToWrite, typeSpeed}: Typewriter) {
  const [finalText, setfinalText] = useState("")

  useEffect(() => {
    let index: number = 0
    const interval = setInterval(() => {
        setfinalText((prev) => prev += textToWrite[index])
        index++
        if (index >= textToWrite.length - 1) {
          clearInterval(interval)
        }
    }, typeSpeed)
  }, [])
  

  return (
    <p className="mt-5 font-mono font-bold">{finalText}</p>
  )
}

export default Typewriter

And i have a problem where the second letter of the text don't seem to show. For example "Hello, world" will become "Hllo, world" and i really don't know how to fix this.

I'm still a beginner so the problem may be obvious, i didn't try anything in particular so any help is welcome.


Solution

  • To be honest, I haven't pinpointed the exact reason for the behavior. But in general I often find that intervals, state updates, and closures make for bad bedfellows in React. Not because of the tooling itself, but because they can be difficult to keep straight intuitively.

    A pattern I find easier to reason about is to use a timeout instead of an interval and rely on the useEffect dependency to re-run the timeout after the state update.

    For example, consider this implementation instead:

    function Typewriter({textToWrite, typeSpeed}) {
      const [finalText, setfinalText] = useState("");
    
      useEffect(() => {
        if (finalText.length < textToWrite.length) {
          const timeout = setTimeout(() => setfinalText(textToWrite.substring(0, finalText.length + 1)), typeSpeed);
          return () => clearTimeout(timeout);
        }
      }, [finalText]);
    
      return (
        <p className="mt-5 font-mono font-bold">{finalText}</p>
      );
    }
    

    The useEffect now uses finalText as a dependency, so it will re-execute any time that state value changes. But what the effect does is considerably simpler.

    Rather than relying on a manually tracked index, we can just compare the lengths of the strings. If the displayed text is still shorter than the complete text, this sets a timeout to update the displayed text to a one-character-larger substring of the supplied text.

    Note also that, when setting the timeout, it returns a function to clear the timeout. This is generally a good practice for effects that may continue to run after a component is destroyed, even if only for a moment. This allows React to clean up those effects.

    Basically, since the component has to re-render any time you update the text anyway, we can rely on that re-render to invoke the next update. (Conditionally, until there's no more update to make.)