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:
remark parses Markdown into mdast
remark plugins can modify the mdast
The mdast is converted to HAST (HTML AST)
rehype plugins can modify the HAST
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:
Parse Markdown using remark-parse
Converts Markdown string to an Abstract Syntax Tree (AST)
Example: "# Hello" → heading node with text "Hello"
Transforms Markdown AST to HTML AST using remark-rehype
Example: heading node → <h1> element
Apply rehype plugins
Modifies HTML AST (e.g., add attributes to links)
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.