Skip to main content

Rendering Markdown in React without react-markdown

Written by on .

engineering
react
markdown

There appear to be literally a thousand ways to render markdown in React.

Up to now, I've been using react-markdown and I was happy with it.

However, I ran into an issue integrating KaTex and react-markdown did not make it easy to troubleshoot.

Dangerously set inner HTML

The main reason I chose to use react-markdown initially was that it advertised it did not rely on dangerouslySetInnerHTML.

It turns out that the same can be achieved by using rehype-react, which is a plugin that transforms HTML into React components.

Rehype and Remark

To understand the Markdown-to-React conversion process, it's crucial to grasp the roles of rehype and remark.

  • remark - Markdown processor that parses Markdown text into mdast (Markdown Abstract Syntax Tree) format.

    • Key features:
      • Parses Markdown into a structured AST
      • Offers a plugin system for custom Markdown processing
      • Supports various Markdown flavors and extensions
  • rehype - HTML processor that works with HAST (HTML Abstract Syntax Tree).

    • Key features:
      • Processes HTML ASTs
      • Provides plugins for HTML modifications
      • Facilitates the conversion of HTML to other formats

These tools work together in a pipeline to convert Markdown into React elements:

  1. remark parses Markdown into mdast
  2. remark plugins can modify the mdast
  3. The mdast is converted to HAST (HTML AST)
  4. rehype plugins can modify the HAST
  5. Finally, the HAST is converted to React elements

React hook to render markdown

Upon becoming aware of the existence of rehype-react, I started to implement a React hook that would allow me to render Markdown without using higher-level abstractions like react-markdown.

This is what I came up with:

import { type Components } from 'hast-util-to-jsx-runtime'; import { type Options as RemarkRehypeOptions } from 'mdast-util-to-hast'; import { type ReactElement, useCallback, useState } from 'react'; import * as jsxRuntime from 'react/jsx-runtime'; import rehypeReact from 'rehype-react'; import remarkParse, { type Options as RemarkParseOptions } from 'remark-parse'; import remarkToRehype from 'remark-rehype'; import { type PluggableList, unified } from 'unified'; export type UseRemarkOptions = { onError?: (err: Error) => void; rehypePlugins?: PluggableList; rehypeReactOptions?: { components?: Partial<Components>; }; remarkParseOptions?: RemarkParseOptions; remarkPlugins?: PluggableList; remarkToRehypeOptions?: RemarkRehypeOptions; }; export const useRemark = ({ onError = () => {}, rehypePlugins = [], rehypeReactOptions, remarkParseOptions, remarkPlugins = [], remarkToRehypeOptions, }: UseRemarkOptions = {}): [null | ReactElement, (source: string) => void] => { const [reactContent, setReactContent] = useState<null | ReactElement>(null); const setMarkdownSource = useCallback((source: string) => { unified() .use(remarkParse, remarkParseOptions) .use(remarkPlugins) .use(remarkToRehype, remarkToRehypeOptions) .use(rehypePlugins) .use(rehypeReact, { ...rehypeReactOptions, Fragment: jsxRuntime.Fragment, jsx: jsxRuntime.jsx, jsxs: jsxRuntime.jsxs, }) .process(source) .then((vfile) => setReactContent(vfile.result as ReactElement)) .catch(onError); }, []); return [reactContent, setMarkdownSource]; };

This code is heavily inspired by react-remark. The latter basically does the same thing, but depends on older versions of remark and rehype.

Here is how it works:

  1. Parse Markdown using remark-parse

    • Converts Markdown string to an Abstract Syntax Tree (AST)
    • Example: "# Hello" → heading node with text "Hello"
  2. Apply remark plugins

    • Transforms Markdown AST (e.g., add syntax highlighting)
  3. Transforms Markdown AST to HTML AST using remark-rehype

    • Example: heading node → <h1> element
  4. Apply rehype plugins

    • Modifies HTML AST (e.g., add attributes to links)
  5. Convert to React elements using rehype-react

    • Transforms HTML AST to React elements
    • Example: <h1> element → React.createElement('h1', null, 'Hello')

react-markdown vs a custom React hook

I am not aware of any major differences between react-markdown and the custom React hook I suggested.

Ultimately, even if the two implementations are equivalent, I prefer the custom React hook because it allows me a direct control over the conversion process.

The simplicity behind the custom React hook was a surprise to me and what prompted to write this post.