Last week, we learned how to render API data in React. We fetched some data from a server and used it to populate our components. But we didn't learn what to do when the data contains an HTML string that we want to render. Let's do that today!

The API

We'll use the /posts endpoint from JSONPlaceholder for this demo. Because the data doesn't contain any HTML strings, I've modified it into my own Worker script. It's identical to to the original except the .title and .body properties contain HTML strings with headings and paragraphs:

[
{
"userId": 1,
"id": 1,
"title": "<h2>sunt aut facere repellat provident occaecati excepturi optio reprehenderit</h2>",
"body": "<p>quia et suscipit</p><p>suscipit recusandae consequuntur expedita et cum</p><p>reprehenderit molestiae ut ut quas totam</p><p>nostrum rerum est autem sunt rem eveniet architecto</p>"
},
{
"userId": 1,
"id": 2,
"title": "<h2>qui est esse</h2>",
"body": "<p>est rerum tempore vitae</p><p>sequi sint nihil reprehenderit dolor beatae ea dolores neque</p><p>fugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis</p><p>qui aperiam non debitis possimus qui neque nisi nulla</p>"
},
{
"userId": 1,
"id": 3,
"title": "<h2>ea molestias quasi exercitationem repellat qui ipsa sit aut</h2>",
"body": "<p>et iusto sed quo iure</p><p>voluptatem occaecati omnis eligendi aut ad</p><p>voluptatem doloribus vel accusantium quis pariatur</p><p>molestiae porro eius odio et labore et velit aut</p>"
}
]

I've only shown the first three posts for brevity.

Rendering HTML as plain text

If we try to render the data as it is, the HTML strings will be rendered as plain text. This is an intentional security feature that prevents us from exposing our users to a cross-site scripting (XSS) attack.

function App() {
const [posts, setPosts] = useState(null);
const [error, setError] = useState(false);

useEffect(() => {
getPosts();

async function getPosts() {
const API = "https://posts.barker.workers.dev/";

try {
const response = await fetch(API);
if (!response.ok) throw new Error();

const data = await response.json();
setPosts(data);
} catch {
setError(true);
}
}
}, []);

if (error) {
return <p className="error">Error: Failed to fetch your posts.</p>;
}

if (!posts) {
return <p>Fetching your posts...</p>;
}

return (
<Fragment>
{posts.map((post) => (
// The .title and .body properties will be rendered as plain text.
<Post title={post.title} key={post.id}>
{post.body}
</Post>
))}
</Fragment>
);
}

We're mapping each post to an instance of the Post component. The .title and .children props will be rendered as plain text.

function Post(props) {
return (
// The .title and .children props will be rendered as plain text.
<article>
<header>{props.title}</header>
<div>{props.children}</div>
</article>
);
}

Demo of rendering HTML as plain text in React.

The dangerouslySetInnerHTML prop

We need to use the dangerouslySetInnerHTML prop to render the strings as HTML instead of plain text:

dangerouslySetInnerHTML is React's replacement for using innerHTML in the browser DOM. In general, setting HTML from code is risky because it's easy to inadvertently expose your users to a cross-site scripting (XSS) attack. So, you can set HTML directly from React, but you have to type out dangerouslySetInnerHTML and pass an object with a __html key, to remind yourself that it's dangerous.

Let's create a new file called util.js (short for "utilities"). We'll declare and export a function called createMarkup(). The function will accept an HTML string called dirty and return an object literal with an __html property. The value of the __html property will be equal to the value of the dirty parameter.

export function createMarkup(dirty) {
return { __html: dirty };
}

Let's import the createMarkup() function in our Post component. We'll use it to set the dangerouslySetInnerHTML prop on the virtual DOM nodes for the <header> and <div> elements.

import { createMarkup } from "./util.js";

function Post(props) {
return (
<article>
<header dangerouslySetInnerHTML={createMarkup(props.title)} />
<div dangerouslySetInnerHTML={createMarkup(props.children)} />
</article>
);
}

Cross-site scripting (XSS)

We haven't sanitized the API data yet. This means we've left our users open to an XSS attack! Because I wrote the API, I know it's safe. But what if the API got hacked and the attacker added some malicious HTML strings to the data?

The attacker could insert JavaScript code that redirects our users to a malicious website. Or they could send all of our users' keystrokes to a remote server that the attacker controls. There's a whole host of bad things they could do!

To demonstrate this, I've created an endpoint that returns an unsafe HTML string. Don't worry, it won't do any real damage—although an attacker would say that! The value of the first object's .body property contains a dodgy <img> tag:

{
"userId": 1,
"id": 1,
"title": "<h2>sunt aut facere repellat provident occaecati excepturi optio reprehenderit</h2>",
"body": "<p>quia et suscipit</p><p>suscipit recusandae consequuntur expedita et cum</p><p>reprehenderit molestiae ut ut quas totam</p><p>nostrum rerum est autem sunt rem eveniet architecto</p><img hidden src='x' onerror='window.addEventListener(\"keydown\", (e) => void console.log(e.key))' />"
}

Let's examine it with HTML syntax highlighting:

<img
hidden
src="x"
onerror="window.addEventListener('keydown', (e) => void console.log(e.key))"
/>
  1. The hidden attribute means the element will be invisible to our users.
  2. The invalid value for the src attribute means the element will fire an error event.
  3. The onerror attribute adds an event listener that logs every keystroke to the console.

Our users would be none the wiser if we accidentally inserted this element, but all their keystrokes would be logged to the console! Logging them to the console is harmless, but what if the attacker sent them to a remote server?

Demo of XSS in React.

Sanitizing the data

Before we render the third-party data, we must sanitize it! There is a native HTML Sanitizer API in the works, but it's still experimental at the time of writing. Until it becomes widely available, we can use DOMPurify. This is a library that's been thoroughly vetted by web security experts.

Here's what we need to do:

  1. Install DOMPurify (npm install dompurify).
  2. Import the DOMPurify.sanitize() method in our util.js file.
  3. Sanitize the value of the dirty parameter before setting the __html property.

DOMPurify will remove all dangerous attributes, including the onerror attribute on the <img> tag.

import { sanitize } from "dompurify";

function createMarkup(dirty) {
return { __html: sanitize(dirty) };
}

export { createMarkup };

Demo of safely rendering HTML in React.

Summary

Avoid setting HTML from code if possible. It's much safer to create the virtual DOM nodes yourself and insert only plain text. If you must insert an arbitrary HTML string, you can use the dangerouslySetInnerHTML prop. You must sanitize the string before inserting it, otherwise you risk exposing your users to a cross-site scripting (XSS) attack. Until the HTML Sanitizer API becomes available, you can use DOMPurify.

For more information, see How to reduce your risk of cross-site scripting attacks with vanilla JavaScript.