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
Changing your
typeCharacter
function approach to the following fixes the issue. I’m not sure why it happens, but it seems thatsetTimeout
somehow misses it.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 usinguseState
for the index.Here’s what I got for the end:
As the comments pointed out, the original
Wweellccoommee!!
issue with the doubling up letters was because you were running in dev mode withReact.StrictMode
wrapping your App. In this situation, React will double-invoke certain functions, including youruseEffect()
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 youruseEffect()
callback isn’t double-invoked anymore. This leaves you with a new issue that you asked about in the comments:The reason this occurs is that React doesn’t necessarily call your state updater function synchronously:
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:In the above example, what occurs is:
useEffect()
after the initial renderi
is set to0
andtypeCharacter
invoked.setDisplayText(prevText => prevText + text.charAt(i));
runsprevText => prevText + text.charAt(i)
is ran immediately (synchronously)prevText + text.charAt(i)
is computed, producing the new state of'W'
setTimeout(typeCharacter, speed);
queuestypeCharacter
to be called after 100ms (speed
).i
, nowi
is1
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 timetypeCharacter
was ran (ie:0
). But let’s now see what happens after yoursetTimeout()
re-executestypeCharacter
for the second time, noting thati
is currently1
:setDisplayText(prevText => prevText + text.charAt(i));
runs again. However, this time the state updater functionprevText => prevText + text.charAt(i)
does not run synchronously. Instead, react queues it and will run it on the next render.setTimeout()
queues yourtypeCharacter
function againi
increments to2
. 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 ofi
that is2
, it’s skipped index1
!i
equalling 2, skipping the second lettere
.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:
If it’s given a value of
"W"
, it may return"We"
, or it may return"Wx"
, etc… depending on what the values oftext
andi
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 usei
as your state, and then take aslice()
of your input text to derive thedisplayText
:Above,
i => i + 1
is a pure function. If it’s passed0
, it’ll always output1
, if it’s passed1
, it’ll always output2
. Since it’s pure, it doesn’t rely on external variables (such asi
in your updater function), which can potentially change.