Read Time in Real-Time with React

by Jack Pritchard

Hey! Just so you know, this article is over 2 years old. Some of the information in it might be outdated, so take it with a grain of salt. I'm not saying it's not worth a read, but don't take everything in it as gospel. If you're curious about something, it never hurts to double-check with a more up-to-date source!

How badass is that title? Wait, what do you mean we're live?

Oh, hello reader.

In today's blog post we're going to be covering how to develop a smart component I came across in my time browsing the web.

The component is a 'read time'counter which estimates how long you have left reading before you finish reading an article. As you scroll down the page, the timer decreases and adds a little spice to an otherwise static page.

After brainstorming, I devised a plan on how to build it and whipped up the solution in an hour. When I presented it to my front-end friend he mentioned that he would have built it in a much more straightforward way.

I'll be going into both methods and why you'd want to pick either.

Before I show you my solution lets first break down the scenario and problem at hand.

Scenario

#

You have a blog post with 5000 words in it (calm down Shakespeare) and you want to show the read time for the blog post.

If we take the average reading time approximate of 250 words per minute, that'd be 20 minutes.

20 minutes is our initial counter value and show be added to the counter if the reader scrolls back to the top of the page.

As the reader scrolls, past word 250, 500, 750, etc. the counter should decrease by an interval of 1.

When the bottom of the article is in view, you could reduce this counter to 0 or have a success/end message for your user.

My Solution (More Accurate)

#

I'll be honest, the end-user of your web application probably won't lose sleep over the fact that your time counter isn't 100% accurate. However, the method I've used will get you as accurate as you can get (or I assume).

Imports

#

Let's dig into the code.

First off we are importing some React dependencies.

To help with processing the HTML we will pull in as our article text source, we need the ReactHtmlParser, and from React to manage lifecycle events the UseEffect hook, as well as the useState hook to update the current count of minutes left reading.

ReactDOM is because this is a CRA (Create React App) that I have booted up on CodeSandbox.

import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import ReactHtmlParser from 'react-html-parser';

Our Application

#

In our application, we are setting up some state variables -

const [checkpoint, setCheckpoint] = useState(0);
const [parsed, setParsed] = useState(undefined);
const [totalWords, setTotalWords] = useState(0);

Checkpoint is used to show how long is left reading the current article.

Parsed is the HTML we want to render on the page, at first this is undefined but after we process the HTML string we are importing into our application, we will want to set the parsed variable to equal the processed HTML.

Finally, we have a variable for the total words count. This is used to determine how many checkpoints we'll include in our article text.

First Lifecycle Method

#

Next, we come to our first lifecycle method. This method gets called once as the useEffect hook has an empty dependency array.

In this method, we are first gathering the HTML string from the external file text.js

Next, we are splitting this string into an array of values. Each array item is created between each space. E.g “hello world” would become [“hello”, “world”].

From this array, we have the number of words as the number of entries in the array. We'll set this number to the totalWords variable in state.

The following step is an important one, we are looping through each word and if it's a multiple of 250 we are wrapping the word in an HTML span tag with a class of checkpoint and a data attribute of checkpoint and the current index of the counter.

For example, the 250th word would be wrapped in a span with the data-checkpoint value of 1. 500th word would be a value of 2, etc.

We'll be using these values and classes later to update the read time left.

Next, we are merging our processed array of values back into a string as we originally had, but now with the span elements, we needed to wrap the text in.

Before, finally, we set this parsed HTML string into the state variable 'parsed'.

useEffect(() => {
const allText = text.html;
const words = allText.split(' ');
setTotalWords(words.length);
const wordCheckpoints = words.map((word, index) =>
index > 0 && index % WORDS_PER_MINUTE === 0
? `<span class="checkpoint" data-checkpoint="${
index / WORDS_PER_MINUTE
}">${word}</span>`
: word,
);
const squashed = wordCheckpoints.join('`);
setParsed(squashed);
}, []);

Phew.

Before we dig into the next lifecycle method, let's look at our render function.

Rendering

#

In our return or render function you'll see we have our ReadTime component which is a simple counter fixed on our screen.

Followed by an article, this article will at first contain undefined which is nothing to the DOM, unless the parsed variable is set. The parsed variable is set to undefined at first which means nothing in the article is shown until our first lifecycle method is complete (as shown above).

When the variable is set to a string and not undefined, we will use the ReactHtmlParser to parse the HTML string into real DOM nodes (“

Hello

” would actually render a paragraph and not a string).

<div className="App">
<ReadTime checkpoint={checkpoint} totalWords={totalWords} />
<Article className="article">
{parsed ? ReactHtmlParser(parsed) : undefined}
</Article>
</div>

Second Lifecycle Method

#

In our second lifecycle method, we first query all nodes that contain the class 'checkpoint'which we set when parsing the HTML string around the 250nth words.

For each of these nodes, we are going to listen out for them in our DOM. If the node becomes visible to us, we are going to get the integer value assigned to their 'data-checkpoint'attribute and set the state variable checkpoint to equal that value.

Essentially saying when we hit word 2500, we set the checkpoint to 10. This value will be taken away from the total read time showing that there is 10 minutes left of a 20-minute article.

At the end of this lifecycle event, we are adding the dependency of the state variable 'parsed'. What this means is, only run this lifecycle method when that value is changed.

This is important as we don't want to query the nodes with the class checkpoint before they exist as a result of our parsing lifecycle method. It's essentially allowing us to wait for our nodes to exist before listening to them.

useEffect(() => {
const checkpoints = document.querySelectorAll('.checkpoint');
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0) {
const checkpointHTMLElement = entry.target;
setCheckpoint(checkpointHTMLElement.getAttribute('data-checkpoint'));
}
});
});
checkpoints.forEach(checkpoint => {
observer.observe(checkpoint);
});
}, [parsed]);

Our ReadTime Component

#

Our read time component is fairly basic, it takes two props which are used to calculate the read time left.

Using the maximum read time - the current checkpoint to calculate minutes left.

The CSS uses position: fixed; to keep it on-screen at all times as we scroll down the page.

const ReadTime = ({ checkpoint, totalWords }) => {
const time = Math.ceil(totalWords / WORDS_PER_MINUTE);
return <div className="readtime">{time - checkpoint} Minutes left</div>;
};

Finishing Up

#

There are two pieces of functionality left out of this tutorial and those include resetting the counter at the top of the page and setting the counter to 0 at the bottom of the page.

These would both be done with event listeners at the top and bottom of the article but to reduce the amount of code in this article I've left them out.

Alternative Solution (Quicker Implementation)

#

The alternative implementation of a time left to read counter would use a scroll event listener. The pseudocode logic is as follows -

  1. Calculate the number of words in article content
  2. Use the number of words divided by 250 to calculate the estimated minutes to read the article
  3. You would create a read time component similar to my example to feedback the current time left
  4. Next, you'd add a scroll event listener to your article container.
  5. If your article takes 20 minutes to read, that means 1 percent of the article height (roughly) is 20 minutes divided by 100 or 1200 seconds divide by 100.
  6. So for every single percentage of the article, you scroll through, you would subtract 12 seconds (1200 / 100). 5 percent of the article would be 1 minute (1200 / 100) * 5, 10 percent 2 minutes (1200 / 100) * 10, etc.

This seems like a much simpler solution, why not use this?

#

While this solution is a much quicker solution it isn't completely accurate.

Let's take an example of an article which covers the subject of a networking event. An article summarising such an event could include -

With the addition of multiple multimedia components, an article could be made up say for example of 30% in imagery. Time scrolling through this media would reduce the time left to read significantly as it doesn't require as much time investment as blocks of text.

Scrolling past a large number of images could subtract minutes of reading when in fact the total read time would be left slightly skewed and inaccurate.

Again, let me reiterate that your readers won't lose sleep over it and at the end of the day the total read time estimated at the start should still hold true.

I guess my solution took accuracy a bit too far in hindsight but if you want to create either solution you now have the information required to develop your solution!

Happy coding :)

Further Learning

#

If you want to learn more about JavaScript and React, follow me on Twitter (@whatjackhasmade) where I share the latest information and news as it comes out.

I'd also love to chat about your ideas and implementations of the read time component and any suggestions you have!