Client-side navigation and React's dangerouslySetInnerHTML
Published on in JavaScript, React and TypeScript
Last updated on
To handle internal link clicks when using dangerouslySetInnerHTML
,
attach click event listeners to the rendered links.
In the listener, push the link to the browser history.
Table of contents
Overview
- Render the HTML with
dangerouslySetInnerHTML
. Remember to use only safe HTML! - When the component mounts, attach click event listeners to the links. In the event listener, push the link to the browser history if it's an internal link.
- When the component unmounts, remove the click event listeners to avoid memory leaks.
Sample code
Using TypeScript:
import React, { RefObject, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
export default function HtmlContent({ html }: { html: string }) {
const ref = useRef<HTMLDivElement>(null)
useLinkClickHandlers(ref)
return <div dangerouslySetInnerHTML={{ __html: html }} ref={ref} />
}
function useLinkClickHandlers(ref: RefObject<HTMLDivElement>) {
const navigate = useNavigate()
useEffect(() => {
if (!ref.current) return
const links = ref.current.querySelectorAll('a')
links.forEach((link) => link.addEventListener('click', handleLinkClick))
return () => {
links.forEach((link) =>
link.removeEventListener('click', handleLinkClick)
)
}
function handleLinkClick(event: MouseEvent) {
const link = event.currentTarget as HTMLAnchorElement
const href = link.getAttribute('href')
const target = link.getAttribute('target')
const url = new URL(href || '', window.location.origin)
const isInternalLink = url.origin === window.location.origin
const isOpenedInSameWindow = !target || target === '_self'
const isLeftButtonClick = event.button === 0
// E.g. Ctrl-clicking a link opens it in a new tab
// so let the browser handle such clicks
const isModifierKeyPressed =
event.altKey || event.ctrlKey || event.metaKey || event.shiftKey
if (
isInternalLink &&
isOpenedInSameWindow &&
isLeftButtonClick &&
!isModifierKeyPressed
) {
event.preventDefault()
navigate(url.pathname + url.search + url.hash)
}
}
}, [navigate, ref])
}
I'm using React Router's useNavigate
hook.
If you don't use React Router,
you can do something similar yourself.
The useNavigate
hook pushes the link to the browser history
(or replaces instead of pushes, depending on how the hook is used).
The handleLinkClick
function is similar to
React Router's useLinkClickHandler
hook
(which can't be used in this case since it takes a link address as a parameter).
Usage
// This could come from a content management system (CMS) for example
const myHtml = /* HTML */ `
<p>Hello from HTML!</p>
<p>
Here's an internal link:
<a href="/foo">foo</a>
</p>
<p>
Another link:
<a href="/bar" target="_blank">bar</a>
</p>
`
function App() {
return <HtmlContent html={myHtml} />
}
By the way,
because of the /* HTML */
comment,
Prettier formats the template literal as HTML.
Why not attach a click event listener to the parent?
Instead of finding all <a>
elements and attaching click event listeners to them,
you technically could attach a click event listener only to the parent <div>
.
In the listener function you could then check if the click was done over a link,
and then handle the link click like in the code above.
Like so:
import React, { RefObject, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
export default function HtmlContent({ html }: { html: string }) {
const ref = useRef<HTMLDivElement>(null)
useLinkClickHandler(ref)
return <div dangerouslySetInnerHTML={{ __html: html }} ref={ref} />
}
function useLinkClickHandler(ref: RefObject<HTMLDivElement>) {
const navigate = useNavigate()
useEffect(() => {
if (!ref.current) return
ref.current.addEventListener('click', handleLinkClick)
return () => ref.current.removeEventListener('click', handleLinkClick)
function handleLinkClick(event: MouseEvent) {
const link = (event.target as HTMLElement).closest('a')
if (!link) return
// Handle link click like in the previous example
}
}, [navigate, ref])
}
However, I don't recommend this because it would be confusing for screen reader users.
Attaching a click event listener to a non-interactive element like <div>
makes some screen readers (e.g. NVDA)
announce the element as "clickable."
In this case,
the <div>
element is not clickable per se
even though a click handler has been attached to it –
the links are clickable, not the <div>
element –
so announcing the <div>
as "clickable" is confusing.
That's way it's better to attach click event listeners to the <a>
elements instead.
Alternative approach
You can use a library like html-react-parser or htmr
to convert the HTML string to React components.
<a>
elements could be converted e.g. to React Router's <Link>
components.