wilson staley

How To Make a Typed-Text React Component

I recently built a "Typed Text" React component to use on my website. This component will take any text and animate it to look like it is being typed out. Today I want to walk through how I built this component as I think it is a great exercise to help build your React skillz.

The Plan

There are many ways to approach this! Let me outline how I'd like to accomplish this with a custom React component...

This "TypedText" component will take in some text via the children prop. It will be responsible for simply animating the text, no fancy styling or anything like that. Then any text we want to appear "typed", we can wrap in the <TypedText> component. We'll perform the animation by initially rendering an empty string, and then appending each character, one at a time, on a set interval. We'll do this until the full string has been rendered, and it appears to have been typed out onto the screen! Here's what the final results will look like:

Text with typing animation

We'll also add a "delay" prop so you customize the time to delay between revealing characters.

Let's build it!

Let's start by creating a basic component:

import React from 'react'

const TypedText = ({ children }) => {
  return <>{children}</>
}

export default TypedText

All this does is return the text provided via the children prop... Pretty pointless at the moment. Now let's add some state to track how many letters have been revealed:

import React, { useState } from 'react'

const TypedText = ({ children }) => {
  const [revealedLetters, setRevealedLetters] = useState(0)

  return <>{children.substring(0, revealedLetters)}</>
}

export default TypedText

Here we are initializing the revealedLetters state to 0 because we want to start with no letters revealed. We are using this state alongside the substring method to only return a subset of the letters that were passed into this component via the children prop.

The substring method does exactly what you'd thing - it returns a substring of the original string it was called on. For example: 'cowboy'.substring(0, 3) === 'cow'

Ok, so we have a way to return a specific subset of the letters that were provided. Now we need a way to increment the number of revealed letters on some time interval. Let's add something to do that:

import React, { useState, useEffect } from 'react'

const TypedText = ({ children }) => {
  const [revealedLetters, setRevealedLetters] = useState(0)
  const interval = setInterval(() => setRevealedLetters(l => l + 1), 110)

  useEffect(() => {
    return () => clearInterval(interval)
  }, [interval])

  return <>{children.substring(0, revealedLetters)}</>
}

export default TypedText

First, we called setInterval to increment the number of revealed letters on a set time interval. The setInterval method takes a function and a delay as parameters. It will continuously call the provided function with a fixed time delay between each call. Looking at this specific bit of code, setInterval(() => setRevealedLetters(l => l + 1), 110), we are providing a function that will increment the revealedLetters state by one every 110 milliseconds.

Now that we understand how setInterval works, we need some way of stopping it! We don't want it to continuously run our function forever! In fact, if we were to just call setInterval() and never tell it to stop incrementing our state, this would be a bug waiting to happen. Why? Because when our TypedText component unmounts, setInterval would continue to try updating the state for a component that is no longer in the dom. If you've ever tried to update state on an unmounted component, you've probably seen an error like this popup in your console:

React error

Notice what it says at the end of the error message: "To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function".

^ This is why we added the useEffect in addition to setInterval. If you return a function from within a useEffect hook, React will execute this function when the component unmounts. This is known as a "cleanup" function and can be used to cancel tasks you don't want happening once the component unmounts. Cleanup functions can be used for many things, like cancelling network requests, but in our case we will use it to clear our interval.

You probably noticed that when we called setInterval(), we assigned its return value to a constant called "interval". The return value from calling setInterval() is an id used to identify the interval that was just created. We need to store a reference to this id so we can cancel the interval later by by passing that id to clearInterval().

All done! Right?

Not quite!

While our TypedText component totally works and is looking good, it is not optimized. To see this, try adding a console.log at the top level of the component like this:

import React, { useState, useEffect } from 'react'

const TypedText = ({ children }) => {
  const [revealedLetters, setRevealedLetters] = useState(0)
  const interval = setInterval(() => setRevealedLetters(l => l + 1), 110)

  console.log('TypedText rendered!')

  useEffect(() => {
    return () => clearInterval(interval)
  }, [interval])

  return <>{children.substring(0, revealedLetters)}</>
}

export default TypedText

If you open up the console, you'll see that "TypedText rendered!" is continually getting printed - the component is needlessly re-rendering itself! Why's it doing this? Because, setInterval keeps updating the revealedLetters state, even after all the letters have been revealed. And in React, updating a component's state will cause the component to re-render.

We should really stop our interval once all of the characters have been revealed. Here's how we'll do that:

import React, { useState, useEffect } from 'react'

const TypedText = ({ children }) => {
  const [revealedLetters, setRevealedLetters] = useState(0)
  const interval = setInterval(() => setRevealedLetters(l => l + 1), 110)

  useEffect(() => {
    if (revealedLetters === children.length) clearInterval(interval)
  }, [children, interval, revealedLetters])

  useEffect(() => {
    return () => clearInterval(interval)
  }, [interval])

  return <>{children.substring(0, revealedLetters)}</>
}

export default TypedText

Here we've added another useEffect hook. This useEffect includes revealedLetters in its dependency array, so it will rerun the function everytime revealedLetters is incremented. All we're doing here is checking if all the letters have been revealed, and if they have been, clearing our interval. This will keep our TypedText component from needlessly re-rendering after all the letters have been revealed.

You might be wondering why there need to be two separate useEffects. Why not just put the returned "cleanup" function in the first useEffect? Well, I haven't given you the entire story about how these cleanup functions work... A cleanup function that is returned from a useEffect is not only called when the component is unmounted. It’s called every time before that effect runs – to clean up from the last run. So if we tried to return our cleanup function from the first useEffect, it would try to clear our interval every time another letter is revealed. That would be no bueno. The good news is that it's perfectly okay to have more than one useEffect in a React component πŸ˜€

Bonus Points

Nowwww our component is much more performant. Let's add just a couple more small things to make this component next level !

First, let's add that optional delay prop I mentioned at the beginning:

import React, { useState, useEffect } from 'react'

const TypedText = ({ children, delay = 110 }) => {
  const [revealedLetters, setRevealedLetters] = useState(0)
  const interval = setInterval(() => setRevealedLetters(l => l + 1), delay)

  useEffect(() => {
    if (revealedLetters === children.length) clearInterval(interval)
  }, [children, interval, revealedLetters])

  useEffect(() => {
    return () => clearInterval(interval)
  }, [interval])

  return <>{children.substring(0, revealedLetters)}</>
}

export default TypedText

As you can see, we've added a simple "delay" prop. We are giving it a default value of 110, so that if the user does not specify this prop, it will have a reasonable default. Then we simply replace the hardcoded 110 milliseconds in the setInterval() with the delay variable. Easy peasy.

Next, let's memoize this component:

import React, { memo, useState, useEffect } from 'react'

const TypedText = ({ children, delay = 110 }) => {
  const [revealedLetters, setRevealedLetters] = useState(0)
  const interval = setInterval(() => setRevealedLetters(l => l + 1), delay)

  useEffect(() => {
    if (revealedLetters === children.length) clearInterval(interval)
  }, [children, interval, revealedLetters])

  useEffect(() => {
    return () => clearInterval(interval)
  }, [interval])

  return <>{children.substring(0, revealedLetters)}</>
}

export default memo(TypedText)

All we've done here is import memo from react and wrapped our default export in this higher order component. Why would we do this? I think React's documentation explains it best:

β€œIf your component renders the same result given the same props, you can wrap it in a call to React.memo for a performance boost in some cases by memoizing the result. This means that React will skip rendering the component, and reuse the last rendered result... React.memo only checks for prop changes. If your function component wrapped in React.memo has a useState, useReducer or useContext Hook in its implementation, it will still re-render when state or context change.”

And there your have it! An optimized, flexible, awesome React component that can be utilized with any text!