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

  1. Render the HTML with dangerouslySetInnerHTML. Remember to use only safe HTML!
  2. 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.
  3. 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.