Char, Word, Sentence and Paragraph Counter JavaScript and React

In one of my React projects I had to do a character counter for some text inputs, so I thought it would be fun to build a small project to count characters, words, sentences and paragraphs from a textbox in plain ol’ vanilla JavaSCript and also in React.

Repo

Get the full code from my Github repo.

Vanilla JavaScript

Throughout this project I am using JavaScript ES6+ syntax, if you are not familiar with it, post in the comments and I will try to explain.

HTML and CSS

For the HTML I have a textarea to input the text, and a p for the different counts

<div>
  <textarea rows='15' id='text'></textarea>
  <p><strong>Character Count:</strong> <span id='characters'>0</span><br/>
    <strong>Word Count:</strong> <span id='words'>0</span><br/>
    <strong>Sentence Count:</strong> <span id='sentences'>0</span><br/>
    <strong>Paragraph Count:</strong> <span id='paragraphs'>0</span></p>
</div>

CSS is very simple just to style p tag and textarea

p {
  font-family: Lato;
}

textarea {
  width: 100%;
  font-size: 100%;
}

Now, we get to the JavaScript part.

Some set up

First we have BlingJS that allows us to use $ in place of document.querySelector and $$ for document.querySelectorAll.

const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);

Node.prototype.on = window.on = function (name, fn) {
  this.addEventListener(name, fn);
};

NodeList.prototype.__proto__ = Array.prototype;

NodeList.prototype.on = NodeList.prototype.addEventListener = function (name, fn) {
  this.forEach((elem) => {
    elem.on(name, fn);
  });
};

Now we have a debounce function (source) that “limits the rate at which a function can fire”. Since we are gonna be adding an event listener every time a key is pressed on the textarea, as it can have performance issues with large amounts of text.

function debounce(func, wait, immediate) {
	var timeout;
	return function() {
		var context = this, args = arguments;
		var later = function() {
			timeout = null;
			if (!immediate) func.apply(context, args);
		};
		var callNow = immediate && !timeout;
		clearTimeout(timeout);
		timeout = setTimeout(later, wait);
		if (callNow) func.apply(context, args);
	};
};

Getting bacon

For this example I wanted to load some dummy text on page load, so the below function will call Bacon Ipsum‘s API, making use of the awesome async/await, giving us 3 paragraphs of delicious bacon.

getBacon = async () => {
  const response = await fetch('https://baconipsum.com/api/?type=all-meat&paras=3');
  const body = await response.json();

  if (response.status !== 200) 
    throw Error(body.message);

  return body;
}

Reading the HTML elements

We now need to get the textarea for our text and spans where the counts are gonna display. For that we are making use of BlingJS selector since I do not want to type document.querySelector five times, because I am lazy.

const textarea = $('#text');
const charactersSpan = $('#characters');
const wordsSpan = $('#words');
const sentencesSpan = $('#sentences');
const paragraphsSpan = $('#paragraphs');

Setting the counts

Time to write our function literal that will set the counts. One thing to note is that I am making use of Ramda’s compose to perform a right-to-left function composition. In other words the right-most function will take, in my case, an array, process it, then the resulting value will be the parameter for the second function (from right-to-left) in the list, and so on.

This function accepts a value, which will be our text, then first thing we do is trim this value of any leading and trailing white spaces.

setCounts = value => {
  const trimmedValue = value.trim();
  const words = R.compose(removeEmptyElements, removeBreaks)(trimmedValue.split(' '));
  const sentences = R.compose(removeEmptyElements, removeBreaks)(trimmedValue.split('.'));
  const paragraphs = removeEmptyElements(trimmedValue.split(/\r?\n|\r/));
  
  charactersSpan.innerText = trimmedValue.length;
  wordsSpan.innerText = value === '' ? 0 : words.length;
  sentencesSpan.innerText = value === '' ? 0 : sentences.length;
  paragraphsSpan.innerText = value === '' ? 0 : paragraphs.length;
}

Word Count

const words = R.compose(removeEmptyElements, removeBreaks)(trimmedValue.split(' '));

To get the word count we have to split the value by spaces (‘ ‘), which will result in an array of words. When we press the Enter key in the textarea, the array of words will come with some array elements like this [“??Turkey”,”pork”,”cow”,”tri-tip”,”??Bresaola??brisket”,”pork”], as you notice element at index 4 in the example array, has two words divided by two ‘break’ characters, we need to split them and remove the ‘breaks’, so I wrote removeBreaks function literal that takes this array of words and do the heavy lifting of getting rid of these ‘break’ characters.

Then the returning value (an array), will be passed on to the a second function literal removeEmptyElements to remove empty elements in the array ([“turkey.”, “”, “Bacon”]). Finally we get a clean array of words, which we can then take the length to get the word count.

Sentence Count

To get the sentence count we have to split the value by periods (‘.’). then we follow the same dance as with the word count, removing the ‘breaks’ and empty elements.

Paragraph Count

This is a little simpler as we only have to split the value by break, then remove the empty elements in the resulting array.

Character Count

The simplest, take the value’s length and that is it.

Removing the break characters

This function takes an array of words and the first thing to do is find the index of the first element in the array that satisfies the provided testing function, in my case matching a ‘break’ character.

removeBreaks = arr => {
  const index = arr.findIndex(el => el.match(/\r?\n|\r/g));

  if (index === -1) 
    return arr;

  const newArray = [
    ...arr.slice(0,index),
    ...arr[index].split(/\r?\n|\r/),
    ...arr.slice(index+1, arr.length)
  ];

  return removeBreaks(newArray);
}

Now since this will be a recursive function, we need to know when to break of of the function, so whenever we stop finding ‘break’ characters, we return the current array.

To remove the ‘break’ characters we create a new array (newArray) and make use of the spread operator to fill it up. First we spread the current array arr from 0 to index, then split the array element where we found the ‘break’ character by ‘breaks’ and spead it, finally we spread the array from index to the end of the array arr.

Then we return the same function with newArray making it recursive.

Removing empty elements

Takes an array of words, and again just like the previous function, the first thing to do is find the index of the first element in the array that satisfies the provided testing function, in my case an empty element.

removeEmptyElements = arr => {
  const index = arr.findIndex(el => el.trim() === '');

  if (index === -1) 
    return arr;

  arr.splice(index, 1);

  return removeEmptyElements(arr)
};

Now since this will be a recursive function, we need to know when to break of of the function, so whenever we stop finding empty elements, we return the current array.

To remove the empty element, we just splice the array, getting rid of the element at index.

Then we return the same function with the arr making it recursive.

Loading bacon on window load

We call the async getBacon function on window load, then assign the result to the textarea and fire up setCounts function to calculate all the counts.

window.on('load', () => {
  getBacon()
    .then(bacon => {
      const text = bacon.join('\n\n');
      textarea.innerText = text;
      setCounts(text);
    })
    .catch(err => textarea.innerText = `Error: ${err.message}`);
});

Listening to key presses

Finally, we listen to the keyup event on the textarea and call our debounce function to fire up our setCounts function every 130ms to calculate all the counts.

textarea.on(
  'keyup', 
  debounce(e => setCounts(e.target.value), 130)
);

Live Demo

See the Pen Character, Word, Sentence and Paragraph Counter by Esau Silva (@esausilva) on CodePen.0

React

The React app pretty much has the same functions, so I will omit retyping them all over again, so I am just gonna mention them and detailing the other React specific stuff.

State

We will have state that will hold our text and different counts.

state = {
  text: '',
  charCount: 0,
  wordCount: 0,
  sentenceCount: 0,
  paragraphCount: 0
}

On component mount

Inside componentDidMount lifecycle we call getBacon function to get an initial text, set the text state to this initial text, and call setCounts function to process all of our counts.

componentDidMount() {
  this.getBacon()
    .then(bacon => {
      this.setState({ text: bacon.join('\n\n') }, () => this.setCounts(this.state.text));
    })
    .catch(err => this.setState({ text: `Error: ${err.message}` }));
}

Setting the counts

setCounts function follows the same logic as with the vanilla JavaScript version, except that here we call setState function to update the value of our counts and text.

setCounts = value => {
  const trimmedValue = value.trim();
  const words = compose(this.removeEmptyElements, this.removeBreaks)(trimmedValue.split(' '));
  const sentences = compose(this.removeEmptyElements, this.removeBreaks)(trimmedValue.split('.'));
  const paragraphs = this.removeEmptyElements(trimmedValue.split(/\r?\n|\r/));

  this.setState({
    text: value,
    charCount: trimmedValue.length,
    wordCount: value === '' ? 0 : words.length,
    sentenceCount: value === '' ? 0 : sentences.length,
    paragraphCount: value === '' ? 0 : paragraphs.length
  });
}

Listening to changes

We create a function to handle the change on the textarea and pass its value to setCounts.

handleChange = e => this.setCounts(e.target.value);

Render

This is our JSX that will be rendered to the screen.

render() {
  return (
    <div>
      <textarea rows='15' onChange={this.handleChange} value={this.state.text}></textarea>
      <p><strong>Character Count:</strong> {this.state.charCount}<br/>
      <strong>Word Count:</strong> {this.state.wordCount}<br/>
      <strong>Sentence Count:</strong> {this.state.sentenceCount}<br/>
      <strong>Paragraph Count:</strong> {this.state.paragraphCount}</p>
    </div>
  );
}

Live Demo

Bonus

I came up with another version of the function removeBreaks using reduce, it is a lot shorter, but I decided to go for the one up on top.

Anyways, here it is.

removeBreaks = arr => (
  arr.reduce((acc, item) => {
    item.includes('\n') ? acc.push(...item.split('\n')) : acc.push(item)
    return acc;
  }, [])
);

Note

This was a fun project, BUT I wouldn’t use it in production just yet, as it is very dumb and does not take into consideration things like “Mr. Esau Silva“, to the naked eye, this is obviously one single sentence, but to this app it would be counted as two sentences since sentences are being split and counted by the periods (.). But feel free to take this and expand it to make it more “smarter”.



 

Esau Silva
Software Engineer at Region One ESC
Full Stack Software Engineer working with Microsoft technologies, ReactJS is awesome and avid Brazilian Jiu-Jitsu practitioner
If you enjoyed this post, please consider leaving a comment or subscribing to the RSS feed.
Share