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.
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.)