Tutorial: Build a Typewriter effect component
If you haven't already, try to solve it yourself first before reading the solution!
First step is to clone the starter repo, as it already has the scaffolding for the challenge: https://github.com/reactpractice-dev/typewriter-effect.
Next, let's start by just displaying the text once the user clicks submit - without any effect, just to see the flow in place:
const TypewriterEffect = () => {
const [sentence, setSentence] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
const data = new FormData(e.target);
setSentence(data.get("sentence"));
};
return (
<div>
<form onSubmit={handleSubmit} .. > ... </form>
{sentence && <p>You typed {sentence}</p>}
</div>
);
};
Now that this works, we can think about how to show the submitted text character by character.
Since React will update the UI for us, we just need to have a state variable where we hold the latest segment of the sentence to display:
h
he
hel
hell
hello
We can get all the characters in the sentence with Array.from
:
const letters = Array.from(sentence);
Next, we want to display the letters one by one with a delay between them, to simulate the typewriter effect. For this, we can use the setInterval
function.
The logic would roughly look like this:
- store the latest typed letters in a state variable - e.g.
typedSentence
- every half a second, add another letter at the end of the
typedSentence
- when we typed all the letters, stop the interval
const letters = Array.from(data.get("sentence"));
const typewriterIntervalId = setInterval(() => {
if (typedSentence.length === letters.length) {
// we typed all the letters
clearInterval(typewriterIntervalId);
}
// else, type next letter
const nextLetterIndex = typedSentence.length;
setTypedSentence(typedSentence + letters[nextLetterIndex]);
}, 500);
But where to call this snippet?
Since we are triggering the typing effect on the click of a button, we can add it to the submit
form handler.
To keep things more readable, I split the code in three smaller methods - startTyping
for the code that starts the interval, stopTyping
for clearing the interval and typeNextLetter
for updating the state of typedSentence
.
First, we can "start typing" when the user clicks the button:
const handleSubmit = (e) => {
e.preventDefault();
const data = new FormData(e.target);
startTyping(data.get("sentence"));
};
This will start the interval:
const intervalId = useRef();
...
const startTyping = (sentence) => {
setTypedSentence("");
intervalId.current = setInterval(() => {
typeNextLetter(sentence);
}, 500);
};
Notice that we're saving the interval id in a useRef
reference, so we can clear it later, when we're done typing.
The code to type next letter is then as follows:
const typeNextLetter = (sentence) => {
const letters = Array.from(sentence);
if (typedSentence.length === letters.length) {
// we typed all the letters
stopTyping();
return;
}
// else, type next letter
const nextLetterIndex = typedSentence.length;
setTypedSentence(typedSentence + letters[nextLetterIndex]);
};
To stop the typing, we will just call clear interval:
const stopTyping = () => {
clearInterval(intervalId.current);
};
And this should be it - however, if we run the app now, we'll see it doesn't work!
The app just types the first letter and then stops.
Why is that?
It's because when calling setInterval
, it wraps over typeNextLetter
and keeps a reference to it as it was when the interval was called. Thus, when we call setTypedSentence
, it does trigger a rerender of the component and creates a new typeNextLetter
method, but the setInterval
does not know of this - it keeps calling the old typeNextLetter
, with the old value of typedSentence
.
How can we fix this?
As Dan Abramov described in this article (which I highly recommend reading), the solution is to keep a reference to the latest typeNextLetter
method and pass that to the interval. This way, setInterval
always has access to the state of the last render.
To save a reference to the latest typeNextLetter
, we can use useRef
:
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = typeNextLetter;
});
Then, when starting the interval, we can call savedCallback.current
instead of `typeNextLetter:
const startTyping = (sentence) => {
setTypedSentence("");
intervalId.current = setInterval(() => {
savedCallback.current(sentence);
}, 500);
};
And that's it - the interval now works correctly and the sentence gets typed as expected.
Here is the completed component:
import { useEffect, useRef, useState } from "react";
const TypewriterEffect = () => {
const [typedSentence, setTypedSentence] = useState("");
const savedCallback = useRef();
const intervalId = useRef();
// start the interval and save its id,
// so we can stop it once all letters are typed
const startTyping = (sentence) => {
setTypedSentence("");
intervalId.current = setInterval(() => {
savedCallback.current(sentence);
}, 500);
};
// stop the interval
const stopTyping = () => {
clearInterval(intervalId.current);
intervalId.current = null;
};
const typeNextLetter = (sentence) => {
const letters = Array.from(sentence);
if (typedSentence.length === letters.length) {
// we typed all the letters
stopTyping();
return;
}
// else, type next letter
const nextLetterIndex = typedSentence.length;
setTypedSentence(typedSentence + letters[nextLetterIndex]);
};
useEffect(() => {
savedCallback.current = typeNextLetter;
});
const handleSubmit = (e) => {
e.preventDefault();
const data = new FormData(e.target);
startTyping(data.get("sentence"));
};
return (
<div>
<form
onSubmit={handleSubmit}
style={{
display: "flex",
gap: "10px",
}}
>
<input
type="text"
name="sentence"
placeholder="Type a sentence"
style={{ width: "300px" }}
/>
<button type="submit">Display with typewriter effect</button>
</form>
{typedSentence && <p>You typed {typedSentence}</p>}
</div>
);
};
export default TypewriterEffect;
Working with setInterval
and React is not straightforward, which is why practice makes perfect :)
You can check out the working code over on Github: https://github.com/reactpractice-dev/typewriter-effect/tree/solution
How did you solve this challenge?
Share your thoughts and questions in the comments below.
Member discussion