4 min read

Tutorial: Build a Typewriter effect component

💡
This is a tutorial that solves the "Build a Typewriter effect component" exercise.
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.