How to Display Dynamic HTML in React
Advertisement
🧩 Scenario
Architecture Walkthrough
Why React Escapes HTML by Default
React escapes all string content before inserting it into the DOM. If you render <div>{htmlString}</div> and htmlString is "<b>Hello</b>", React outputs the literal text <b>Hello</b> in the browser, not bold text. This is intentional: it prevents XSS (Cross-Site Scripting), where a malicious actor injects executable script tags or event handler attributes into rendered content. By default, React treats all strings as plain text.
This protection covers the most common attack vector, where user-generated content is reflected back into the page.
dangerouslySetInnerHTML
When you need to render actual HTML markup from a trusted source (a CMS, a Markdown-to-HTML converter, an email preview), React provides dangerouslySetInnerHTML. The prop requires an object with a __html key, not a plain string. This naming is intentional friction: the double underscore and the word "dangerous" are signals to the developer that this is an opt-out from React's safety model, requiring deliberate thought.
The prop sets the element's innerHTML directly. It bypasses React's virtual DOM diffing for that element's children, and no child React elements can be placed alongside it on the same element.
Sanitization with DOMPurify
When the HTML source is untrusted (user input, API responses from unknown origins, third-party CMS content), you must sanitize before rendering. DOMPurify strips script tags, event handler attributes (onclick, onerror, etc.), and other XSS vectors while preserving safe formatting tags. Defense in depth means sanitizing on the server as well, not only on the client.
Key Code Explained
// Basic usage: trusted HTML from your own CMS
interface BlogPostProps {
title: string;
htmlContent: string; // HTML string from a trusted CMS
}
function BlogPost({ title, htmlContent }: BlogPostProps) {
return (
<article>
<h1>{title}</h1>
{/* dangerouslySetInnerHTML requires an object with __html key */}
<div
className="prose"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</article>
);
}
// Sanitized usage: untrusted HTML from users or external APIs
import DOMPurify from 'dompurify';
interface CommentBodyProps {
userHtml: string; // user-submitted content — always untrusted
}
function CommentBody({ userHtml }: CommentBodyProps) {
// Sanitize before rendering: strips scripts, event handlers, dangerous attributes
const sanitized = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
});
return (
<div
className="comment-body"
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
// When to avoid dangerouslySetInnerHTML: prefer React components
// BAD: rendering a simple list as HTML string
const htmlList = '<ul><li>Apple</li><li>Banana</li></ul>';
<div dangerouslySetInnerHTML={{ __html: htmlList }} />
// GOOD: render it as React components — no XSS risk, React-managed
const fruits = ['Apple', 'Banana'];
<ul>
{fruits.map((fruit) => <li key={fruit}>{fruit}</li>)}
</ul>
// Server-side sanitization (Node.js with jsdom for DOMPurify)
// src/lib/sanitize.ts
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window as unknown as Window);
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty);
}
The ALLOWED_TAGS and ALLOWED_ATTR configuration is intentional. Passing an allowlist to DOMPurify is safer than relying on the default blocklist, because new XSS vectors are discovered over time. An allowlist only permits what you explicitly need.
Tradeoffs
| Approach | XSS safe by default | Use when |
|---|---|---|
| JSX string interpolation | Yes | All plain text content |
| dangerouslySetInnerHTML (trusted) | Depends on source | CMS HTML, markdown-to-HTML from your own pipeline |
| dangerouslySetInnerHTML + DOMPurify | Yes (when configured correctly) | User-generated HTML, third-party content |
| React components | Yes | Any structured content you control |
What Interviewers Actually Check
- Whether you know that React escapes HTML strings by default and why
- Whether you know
dangerouslySetInnerHTMLrequires{ __html: string }, not a plain string - Whether you know to sanitize untrusted HTML with DOMPurify before rendering
- Whether you can explain what XSS is and why it matters
- Whether you know that
dangerouslySetInnerHTMLshould be avoided when React components can do the job instead
Follow-Up Questions
- How does Content Security Policy (CSP) complement DOMPurify as a defense against XSS?
- What is the difference between stored XSS, reflected XSS, and DOM-based XSS, and which does dangerouslySetInnerHTML protect against?
- How would you render Markdown in React safely using a library like
react-markdowninstead of dangerouslySetInnerHTML? - Can DOMPurify be used in a Node.js (server-side) environment, and what additional setup does it require?
- How does React 19's new
innerHTMLprop relate todangerouslySetInnerHTML?
Common Candidate Mistakes
- Passing user-generated HTML directly to
dangerouslySetInnerHTMLwithout sanitizing it, creating an XSS vulnerability - Not knowing the
{ __html: string }object shape and trying to pass a plain string - Using
dangerouslySetInnerHTMLfor simple structured content that could be expressed as React components - Sanitizing only on the client, not knowing that server-side sanitization is also necessary for defense in depth
- Thinking that DOMPurify with default settings is always sufficient, without knowing that a restrictive allowlist is safer
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you use
dangerouslySetInnerHTMLto render an HTML string from a CMS? - Can you integrate DOMPurify to sanitize HTML before rendering?
- Can you explain why React escapes HTML by default and what XSS is?
- Can you articulate when
dangerouslySetInnerHTMLis appropriate vs when React components are better? - Can you explain why the
__htmlkey exists and what the naming signals to developers?
Summary
React escapes all HTML strings by default, rendering them as literal text rather than markup. This prevents XSS, the most common injection attack where malicious script tags or event handler attributes are executed in the browser. When you genuinely need to inject HTML markup (CMS blog post bodies, markdown-to-HTML output, email previews), React provides dangerouslySetInnerHTML as a deliberate opt-out. The prop requires an object with a __html key, not a plain string, as an intentional signal that this is a conscious security decision.
For HTML from untrusted sources (user-generated content, third-party APIs), always sanitize with DOMPurify before passing to dangerouslySetInnerHTML. Configure DOMPurify with an explicit allowlist of permitted tags and attributes rather than relying on the default blocklist. Defense in depth means sanitizing on the server as well, not only in the client component.
Avoid dangerouslySetInnerHTML whenever the content can be expressed as React components. A list, a table, or a card layout from structured data should be rendered as JSX elements. dangerouslySetInnerHTML is for opaque HTML blobs from CMS and document pipelines, not for structured data you control.
Can I use innerHTML in React?
Not directly. React escapes all HTML by default. Use dangerouslySetInnerHTML with the __html key to opt out of escaping. Always sanitize external HTML before passing it in.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement