In this post I will show you a way to create a unit tested React component that allows you to pass either a plain string, or one or more <p>text goes here</p>
tags, and then render out only the first X words. When the word limit is hit, a ‘Show More’ button will display, and when clicked, all the text will display and a ‘Show Less’ button will display instead.
This is a React component I have made myself for a site of mine. As such it’s not a library or npm
package you can download. However it does come with some tests, so that’s quite nice. This is used in a NextJS project, and tested with Jest (amongst some other dependencies).
As several of my recent posts have covered, I am often working on one of my projects called HighestPayingJobs.co.uk, a website, rather unsurprisingly, about the highest paying jobs here in the UK.
That site has a ton of data, and as a result, a ton of pages. Lately I’ve been going through and adding text to the pages to help make sense of the data for those who actually visit the site. An example would be something like this:
That’s quite a lot of text when you’re on desktop.
On mobile it’s frankly too much text:
In reality the text is there predominantly for Google’s benefit. There’s simply no way I expect the majority of people to actually read that text, at least not initially. People are impatient. I am proof of that.
So what I want to do is truncate that text down to say, the first 50 words.
When the text is truncated I want to show a “Read more” button, which when clicked will expand the text out in full.
And when expanded, a “Read less” button should display, and when clicked it should revert back to the truncated view.
All that is fine, but another problem here is that no one likes a wall of text.
It would be far better to break that text up into several shorter paragraphs. And that creates a problem.
Changing To Multiple Paragraphs
Here’s the text as is:
<div class="prose max-w-full"><h1>Highest Paying <!-- -->IT Jobs<!-- --> in the UK</h1><p>The field of Information Technology (IT) is one of the most lucrative industries in the UK. With rapid technological advancements and a growing reliance on technology across various sectors, the demand for skilled IT professionals has increased significantly. As a result, IT jobs have become some of the highest paying jobs in the UK. In this section, we will explore the top highest paying IT jobs in the UK, the skills required to excel in these roles, and the industries that offer the best job opportunities. Whether you are an experienced IT professional or just starting your career, this guide will provide valuable insights into the most lucrative IT careers in the UK.</p></div>
Code language: HTML, XML (xml)
As I said at the start, it’s a NextJS site running Tailwinds for CSS. I’m not entirely sure why the HTML comments appear before and after the IT Jobs
text, but also I don’t think I need to worry about that.
The point is, I would prefer that to be multiple paragraphs. So let’s change that up straight away:
I like Markdown. I made each text part of the site using a Markdown file, and then used Remark to parse that markdown for display.
The change here is superficial really. I’ve added a line break in where I want a paragraph to be, but that change alone won’t do anything.
Why that’s important to cover is because I then have this next step in the JSX:
It’s interesting actually, because it’s only whilst I’m writing this that I realised I should probably have filtered out the empty lines after splitting:
{sectionIntro
.split("\n")
.filter((x) => x)
.map((section, index) => (
<p key={index}>{section}</p>
))}
Code language: JavaScript (javascript)
Anyway, the upshot of this is that things look nicer (imo) on desktop:
But on Mobile, things better but are actually worse from a UX point of view:
The end user is now having to do even more scrolling to get to the content they (may or may not) care about. Great success.
Implementing The TextContainer Component
Across the site I have a variety of these text inputs.
Most are single paragraphs because of the issue above where I haven’t split the markdown. Others have been split to new lines, like the example above.
What that means is I have a mixture of single and multiple child <p>
elements to contend with. I actually don’t mind this. It’s often best to assume you can have zero, one, or many. And arrays greatly help with this idea.
I will throw in one final caveat before the code that, of course, this can very likely be improved. There are parts I do not like. However I am a pragmatist and would rather have something that works but may not be amazing, rather than an amazing idea that never sees the real world.
Enough blather, here’s the code:
import * as React from "react";
import { useState } from "react";
interface TextContainerProps {
truncateAfterXWords: number;
children: string | ReactElement | ReactElement[];
}
export function TextContainer({
truncateAfterXWords,
children,
}: TextContainerProps) {
const [showFullText, setShowFullText] = useState(false);
const toggleText = () => {
setShowFullText(!showFullText);
};
const truncateText = (text: string, wordCount: number) => {
const words = text.split(" ");
return words.length > wordCount
? `${words.slice(0, wordCount).join(" ")}\u2026`
: text;
};
let accumulatedWords = 0;
let returned = false;
const textToRender = React.Children.map(
children,
(child: React.ReactElement) => {
if (showFullText) {
return child;
}
if (returned) {
return null;
}
if (!React.isValidElement(child) || child.type !== "p") {
return child;
}
const childElement = child as React.ReactElement;
accumulatedWords += String(childElement.props.children).split(" ").length;
if (accumulatedWords >= truncateAfterXWords) {
returned = true;
const truncatedText = truncateText(
childElement.props.children,
truncateAfterXWords,
);
return <p className="inline pr-2">{truncatedText}</p>;
}
return child;
},
);
return (
<div className="prose max-w-full">
{textToRender}
{!showFullText && (
<button onClick={toggleText} className="text-green-700 font-bold">
Read more
</button>
)}
{showFullText && (
<button onClick={toggleText} className="text-green-700 font-bold">
Read less
</button>
)}
</div>
);
}
export default TextContainer;
Code language: TypeScript (typescript)
Before we look at the code in more depth, here’s how to use the component:
<TextContainer truncateAfterXWords={50}>
<p>
In the fast-paced world of technology, programming languages
are the backbone of the digital age, offering exciting
career opportunities and competitive salaries. If you're
eager to explore the UK's programming job market and
discover the top earners among programming languages, you've
come to the right place. These tables delve into the
highest-paid programming languages in the UK over the past
year, offering valuable insights to guide your career
choices.
</p>
<p>
Our focus is on the most recent 12 months, ensuring you have
access to the latest data that matters. Without delving into
intricate details, this page provides a snapshot of the
programming landscape in the UK, empowering you with the
knowledge to make informed decisions for your tech career.
</p>
<p>
Let's explore the recent trends in programming salaries in
the UK and uncover the programming languages that have been
leading the pack in terms of compensation.
</p>
</TextContainer>
Code language: HTML, XML (xml)
Which then renders out to:
And then this when you click Read more:
Not amazing, but … well, it solves the problem.
Going back to the earlier example that uses the split on new lines approach, the code would look like this:
<TextContainer truncateAfterXWords={50}>
{sectionIntro
.split("\n")
.filter((x) => x)
.map((section, index) => (
<p key={index}>{section}</p>
))}
</TextContainer>
Code language: TypeScript (typescript)
That’s it really.
It is, however, doing something with multiple paragraphs that was, for me, a bug in all the other implementations I tried.
This becomes more evident when I show the tests.
Adding Unit Tests
Because the multiple paragraphs element of this component was fairly tricky to implement, I relied fairly heavily on unit tests.
This has been entirely glossed over, until now.
NextJS + Jest Configuration and Setup Steps
As I said above, this is a NextJS project, and Jest testing React components doesn’t play super nicely with this setup fresh out of the box.
Here’s the initial config needed to make these tests play if you’re copying this code over to your NextJS project:
// package.json
"devDependencies": {
"@jest/types": "^29.6.3",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2"
}
Code language: JavaScript (javascript)
I’m not sure why I have both @types/jest
and @jest/types
, nor why two variants exist. I don’t have time to dig into that right now, so I just listed what I have that’s relevant to this.
Next, you will need some further Jest setup:
// jest.config.js
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
const nextJest = require("next/jest");
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
// Add any custom config to be passed to Jest
const customJestConfig = {
preset: "ts-jest",
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testEnvironment: "jest-environment-jsdom",
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);
Code language: JavaScript (javascript)
And finally:
// Learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";
Code language: JavaScript (javascript)
With those in place, you should have no issue running the following Unit test file:
Jest Unit Test File
Here’s the Jest unit tests for the TextContainer
:
import * as React from "react";
import "@testing-library/jest-dom";
import { render, fireEvent } from "@testing-library/react";
import TextContainer from "./TextContainer";
describe("TextContainer Component", () => {
it("renders without errors", () => {
render(
<TextContainer truncateAfterXWords={20}>Some text content</TextContainer>,
);
});
it("renders a single child in full", () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={20}>
<p>Some text content</p>
</TextContainer>,
);
expect(getByText("Some text content")).toBeInTheDocument();
});
it("truncates text after specified words", () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
</TextContainer>,
);
expect(getByText("This is a\u2026")).toBeInTheDocument();
});
it('displays "Read more" button when text is truncated', () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
</TextContainer>,
);
expect(getByText("Read more")).toBeInTheDocument();
});
it('expands text when "Read more" button is clicked', () => {
const { getByText, queryByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
<p>Some more text goes here.</p>
</TextContainer>,
);
fireEvent.click(getByText("Read more"));
// The button "Read more" should disappear, and the full text should be visible
expect(queryByText("Read more")).toBeNull();
expect(getByText("This is a sample text.")).toBeInTheDocument();
expect(getByText("Some more text goes here.")).toBeInTheDocument();
});
it('displays "Read less" button when text is expanded', () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
</TextContainer>,
);
fireEvent.click(getByText("Read more")); // Expand the text
expect(getByText("Read less")).toBeInTheDocument();
});
it('collapses text when "Read less" button is clicked', () => {
const { getByText, queryByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
<p>Some more text goes here.</p>
</TextContainer>,
);
fireEvent.click(getByText("Read more")); // Expand the text
fireEvent.click(getByText("Read less"));
// The button "Read less" should disappear, and the truncated text should be visible
expect(queryByText("Read less")).toBeNull();
expect(getByText("This is a\u2026")).toBeInTheDocument();
});
});
Code language: TypeScript (typescript)
That’s actually everything you need to make this component work.
However, by looking at the individual tests we can see exactly how things behave.
The Unplanned Use Case
it("renders without errors", () => {
render(
<TextContainer truncateAfterXWords={20}>Some text content</TextContainer>,
);
});
Code language: TypeScript (typescript)
This test tells us that the component ought to work with plain string
s.
Whilst you might have thought it just would, I found out this wasn’t the case when it came to typing:
interface TextContainerProps {
truncateAfterXWords: number;
children: string | ReactElement | ReactElement[];
}
export function TextContainer({
truncateAfterXWords,
children,
}: TextContainerProps) {
Code language: TypeScript (typescript)
When specifying the children
I had to explicitly allow for string
literals as well as one or several ReactElement
s.
In truth I wasn’t planning to support plain string
‘s like this. However it was the initial way I wrote the first test, and when it failed I decided to fix it. But the use case isn’t fully supported.
The Most Basic Planned Use Case
it("renders a single child in full", () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={20}>
<p>Some text content</p>
</TextContainer>,
);
expect(getByText("Some text content")).toBeInTheDocument();
});
Code language: TypeScript (typescript)
This is the simplest case of the expected behaviour.
The TextContainer
takes one <p>
element, and the truncateAfterXWords
prop is greater than the number of words in the first paragraph.
The end result is the first paragraph should be rendered in full.
A Basic Truncate
it("truncates text after specified words", () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
</TextContainer>,
);
expect(getByText("This is a\u2026")).toBeInTheDocument();
});
Code language: JavaScript (javascript)
This is the first test of truncating a passed in element.
The setup is as the previous test (cough, copy and paste, cough), except that the truncateAfterXWords
prop is now set to 3
instead of 20
.
Therefore the first paragraph should be truncated after the third word, and the HTML ellipsis element (the three dots…) should display immediately at the cut off point.
I had a bit of fun trying to get the ellipsis to render. Turns out I had to use the unicode \u2026
string to make for a happy outcome, otherwise React was very happy rendering out the raw HTML entity verbatim – …
.
The truncateText
function looks like this:
const truncateText = (text: string, wordCount: number) => {
const words = text.split(" ");
return words.length > wordCount
? `${words.slice(0, wordCount).join(" ")}\u2026`
: text;
};
Code language: TypeScript (typescript)
And that is used in here, which for the moment I have stripped down from the full implementation:
let accumulatedWords = 0;
const textToRender = React.Children.map(
children,
(child: React.ReactElement) => {
accumulatedWords += String(childElement.props.children).split(" ").length;
if (accumulatedWords >= truncateAfterXWords) {
const truncatedText = truncateText(
childElement.props.children,
truncateAfterXWords,
);
return <p>{truncatedText}</p>;
}
return <p>{childElement.props.children}</p>;
},
);
Code language: TypeScript (typescript)
This is really the crux of the whole component’s purpose.
There’s quite a lot happening here, so let’s break it down.
Ignoring accumulatedWords
for the moment, we start on line 3 where we use React.Children.map
, passing in the children
prop as the first argument, and then the mapping function as the second argument.
Unusual?
Well, not in React.
const textToRender = React.Children.map(
children,
(child: React.ReactElement) => {
Code language: TypeScript (typescript)
React.Children.map
is specifically designed for working with React’s child elements, and it offers several advantages over directly using children.map
.
First and foremost, it ensures that child elements, which can come in various forms, such as single elements, arrays, or fragments, are handled correctly. This means you won’t run into unexpected errors when dealing with different child types as we are here.
Another reason to use React.Children.map
is its built in handling of null
and undefined
children. In React, it’s entirely possible for a component to receive null
or undefined
children. Using children.map
directly could lead to errors or unwanted behaviour in such cases. React.Children.map
takes care of these scenarios, making our code more robust for free.
Also when child elements have a key
prop, which is crucial for React’s reconciliation and rendering optimisation, React.Children.map
ensures that these keys are correctly preserved during iteration. If you opt for children.map
, you might encounter key-related issues that can affect the performance of your application.
accumulatedWords += String(childElement.props.children).split(" ").length;
Code language: TypeScript (typescript)
Back on line 1 we declared a variable called accumulatedWords
and initialised it to 0.
accumulatedWords
is used to keep track of the total number of words encountered while processing the React children.
In this line, we take the children
of each React element, convert it to a string, and then split it using spaces into an array of words. The use of String
was necessary to make TypeScript happy more, something I would very likely have missed in a pure JSX solution.
The length of this array is added to the accumulatedWords
variable, effectively counting the words in the current child element.
if (accumulatedWords >= truncateAfterXWords) {
This conditional statement checks if the accumulated word count has reached or exceeded the specified limit we set via props
, which is stored in the variable truncateAfterXWords
.
If it’s true, it means that we have reached the word limit, and the code proceeds to truncate the text.
In this particular implementation, we would end up truncating every paragraph / element passed in. This was a bug I faced initially, so further code (to come shortly) handles that case. For now, this operates per paragraph, which is incorrect, but enough to pass the current test.
const truncatedText = truncateText(
childElement.props.children,
truncateAfterXWords,
);
Code language: JavaScript (javascript)
If the word limit is exceeded, the code calls a function truncateText
with the child element’s text and the truncateAfterXWords
value.
We covered the implementation of this function up above.
return <p>{truncatedText}</p>;
}
return <p>{childElement.props.children}</p>;
Code language: JavaScript (javascript)
Finally we either return the truncatedText
if we hit the conditional, or the full text from the current element if not.
As I say, this particular slimmed down implementation, if used as-is, has some bugs, but it serves the purpose of highlighting the main part of the implementation.
Adding A Button For ‘Read More’
it('displays "Read more" button when text is truncated', () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
</TextContainer>,
);
expect(getByText("Read more")).toBeInTheDocument();
});
Code language: JavaScript (javascript)
This one involves a fairly classic React pattern, which is to useState
, have a toggle function, and then render variations of a component dependant on that state.
Here’s all the relevant code:
import * as React from "react";
import { useState } from "react";
export function TextContainer({
truncateAfterXWords,
children,
}: TextContainerProps) {
const [showFullText, setShowFullText] = useState(false);
const toggleText = () => {
setShowFullText(!showFullText);
};
return (
<div className="prose max-w-full">
{textToRender}
{!showFullText && (
<button onClick={toggleText} className="text-green-700 font-bold">
Read more
</button>
)}
{showFullText && (
<button onClick={toggleText} className="text-green-700 font-bold">
Read less
</button>
)}
</div>
);
Code language: JavaScript (javascript)
We use the useState
hook, which will hold a boolean value that is initially set to false
.
That boolean represents the concept of whether or not to show the full text.
The toggleText
function flips whatever value is currently set in state. Every time it is called it will set the opposite – so if true
then false
, and vice versa.
Depending on whether or not the showFullText
value is true
or false
, we render out either the “Read more” or “Read less” buttons.
Pretty straightforward, all things considered.
Ensure All Text Is Shown When ‘Read More’ Is Clicked
it('expands text when "Read more" button is clicked', () => {
const { getByText, queryByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
<p>Some more text goes here.</p>
</TextContainer>,
);
fireEvent.click(getByText("Read more"));
// The button "Read more" should disappear, and the full text should be visible
expect(queryByText("Read more")).toBeNull();
expect(getByText("This is a sample text.")).toBeInTheDocument();
expect(getByText("Some more text goes here.")).toBeInTheDocument();
});
Code language: JavaScript (javascript)
This test solely focuses on the behaviour of the “Read more” behaviour.
Click the button, it should remove “Read more” from the DOM, and show the two paragraphs we added as children of the TextContainer
.
It doesn’t concern itself with what is rendered instead (the “Read less” button), it’s pretty laser focused. If possible I prefer smaller tests like these, and having more tests to cover the variations, rather than trying to make one or a small number of big tests.
Trust me, when things go wrong, it’s so much easier to isolate why (and, hopefully, how) when you have many smaller tests.
Show The ‘Read Less’ Button
it('displays "Read less" button when text is expanded', () => {
const { getByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
</TextContainer>,
);
fireEvent.click(getByText("Read more")); // Expand the text
expect(getByText("Read less")).toBeInTheDocument();
});
Code language: JavaScript (javascript)
Again, a small test that only focuses on adding the expected button when ‘Read more’ has been pressed.
One point that you may disagree with, but I’m not checking that either ‘Read more’ or ‘Read less’ are buttons. Just that the text appears, can be clicked, and that the component behaves as expected afterwards.
The Hard Case
it('collapses text when "Read less" button is clicked', () => {
const { getByText, queryByText } = render(
<TextContainer truncateAfterXWords={3}>
<p>This is a sample text.</p>
<p>Some more text goes here.</p>
</TextContainer>,
);
fireEvent.click(getByText("Read more")); // Expand the text
fireEvent.click(getByText("Read less"));
// The button "Read less" should disappear, and the truncated text should be visible
expect(queryByText("Read less")).toBeNull();
expect(getByText("This is a\u2026")).toBeInTheDocument();
expect(getByText("Some more text goes here.")).not.toBeInTheDocument();
});
Code language: JavaScript (javascript)
This one looks like all the others, right?
We set up the TextContainer
, click the button, click the variant button, and then check that the text has been properly truncated.
So what’s the big deal?
Well, this is the test that led to all the other logic necessary in the final version of the TextContainer
component above.
At this point I couldn’t avoid the following:
<p>This is a...</p>
<p>Some more text...</p>
Code language: HTML, XML (xml)
This had been the bug all along that had led to me having to write my own implementation as opposed to using a third party library, or finding a steal-able solution from Stack Overflow. Hey, we’ve all done it.
I didn’t (and don’t) want every paragraph to be truncated.
I want to truncate after a certain number of words, even if the second or third or fiftieth paragraphs have to be rendered.
Take this as an example:
<TextContainer truncateAfterXWords={8}>
<p>This is a sample text.</p>
<p>Some more text goes here.</p>
</TextContainer>
Code language: HTML, XML (xml)
That should render:
<p>This is a sample text.</p>
<p>Some more text...</p>
Code language: HTML, XML (xml)
Cutting off in the second paragraph, but rendering the first in full.
Makes intuitive sense.
But in order to make that work, I had to really muddy up the code.
let accumulatedWords = 0;
let returned = false;
const textToRender = React.Children.map(
children,
(child: React.ReactElement) => {
if (showFullText) {
return child;
}
if (returned) {
return null;
}
if (!React.isValidElement(child) || child.type !== "p") {
return child;
}
const childElement = child as React.ReactElement;
accumulatedWords += String(childElement.props.children).split(" ").length;
if (accumulatedWords >= truncateAfterXWords) {
returned = true;
const truncatedText = truncateText(
childElement.props.children,
truncateAfterXWords,
);
return <p className="inline pr-2">{truncatedText}</p>;
}
return child;
},
);
Code language: JavaScript (javascript)
I’m really not mad keen on having state hanging around like this, but I couldn’t find a nicer way.
returned
… eughh. What a horrible name for a variable.
We will revisit that in a moment.
First up on line’s 6-8 we don’t really need to do any logic if we are rendering the full text. Just render everything in this child element and return early. Saved some CPU cycles, making for a happier planet.
Second, line’s 8-10, there’s that returned
again. Eughh.
OK, so what is this doing?
Well, on line 22 we need a way to stop rendering further if we already hit the truncation point.
So line 22 uses this returned
variable to track that we’re already done rendering and have, in a way, already returned everything we want to display. It’s not a great name.
What that does mean is that on subsequent iterations of the map
– any subsequent loops if you think of this kinda like the old school for
loops – then we won’t need to render anything. That takes us back to lines 8-10.
And that’s really it.
I actually realised during the write up here that the last line (line 30):
return child;
Code language: JavaScript (javascript)
Could be refactored from <p>{childElement.props.children}</p>
as I had it originally.
There’s nothing else this element could be at this point, as best I can tell.
Lines 14-16 sort of cover a case I haven’t yet hit on, but might. This is that if the children passed in to the TextContainer
are not paragraphs, then just render whatever is given. There’s no way to truncate an image… I guess. But I don’t actually render images on my site. Not yet anyway. So it’s not an issue for me.
Wrapping Up
That covers the implementation and tests for the TextContainer
component. This React component gives us a way to truncate multiple paragraphs of text without suffering from the problem of truncating each paragraph. When using this component you will have a ‘Read more’ and ‘Read less’ button that show or hide, depending on whether you have expanded the full text.
Feel free to use, modify, and adapt to your own needs. It’s not a library so you can tweak it as desired. Remember it’s got some Tailwinds specific CSS in there which, if you’re not using Tailwinds (😮) you should change.
It’s not widely battle tested, but as a starting point it should hopefully get you going. Feel free to comment and share a link if you use it out there on the web.