React Markdownの実装方法|マークダウンを簡単に表示する

React Markdownを使ってマークダウンコンテンツを表示する方法を解説。基本的な実装からカスタマイズ、セキュリティ対策まで詳しく紹介

Learning Next 運営
67 分で読めます

みなさん、こんなことありませんか?

「Reactでマークダウンを表示したい」 「ブログ記事をマークダウンで管理したい」 「どうやって実装すればいいか分からない」

マークダウンは文章を書くのに便利ですよね。 でも、Reactで表示するとなると「どうしよう?」と悩んでしまいます。

大丈夫です! React Markdownを使えば、とても簡単にマークダウンを表示できるんです。

この記事では、React Markdownの使い方を詳しく解説しますね。 基本的な実装からカスタマイズまで、実用的な内容をお伝えします。

読み終わる頃には、あなたもマークダウンの表示ができるようになるはずです!

React Markdownって何?

React Markdownは、Reactアプリでマークダウンを表示するためのライブラリです。

基本的な仕組みを見てみよう

React Markdownがどう動くか、まずは簡単な例を見てみましょう。

// マークダウンテキスト
const markdownText = `
# タイトル

これは**太字**で、これは*斜体*です。

- リスト項目1
- リスト項目2
- リスト項目3

## サブタイトル

コードも表示できます:

\`\`\`javascript
const message = "Hello World";
console.log(message);
\`\`\`
`;

// React Markdownで表示
import ReactMarkdown from 'react-markdown';

function MarkdownDisplay() {
  return (
    <div>
      <ReactMarkdown>{markdownText}</ReactMarkdown>
    </div>
  );
}

このコードの流れを説明しますね。

まず、markdownTextという変数にマークダウンの文字列を入れています。 #は見出し、**は太字、*は斜体、-はリストを表現しています。

次に、ReactMarkdownコンポーネントでその文字列を囲みます。 これだけで、マークダウンがきれいなHTMLに変換されるんです。

実際に変換されると、こんなHTMLになります。

<h1>タイトル</h1>
<p>これは<strong>太字</strong>で、これは<em>斜体</em>です。</p>
<ul>
  <li>リスト項目1</li>
  <li>リスト項目2</li>
  <li>リスト項目3</li>
</ul>
<h2>サブタイトル</h2>
<p>コードも表示できます:</p>
<pre><code class="language-javascript">const message = "Hello World";
console.log(message);
</code></pre>

マークダウンの記法が、ちゃんとしたHTMLタグに変換されていますね。 これで、ブラウザがきれいに表示してくれます。

他の方法と比べてどうなの?

マークダウンを表示する方法は他にもありますが、React Markdownの良さを見てみましょう。

// ❌ dangerouslySetInnerHTMLを使用(危険)
function UnsafeMarkdown({ content }) {
  const htmlContent = someMarkdownParser(content);
  return (
    <div dangerouslySetInnerHTML={{ __html: htmlContent }} />
  );
}

// ✅ React Markdown(安全でカスタマイズしやすい)
function SafeMarkdown({ content }) {
  return (
    <ReactMarkdown>{content}</ReactMarkdown>
  );
}

上の例で「危険」と書いているのは、dangerouslySetInnerHTMLという方法だからです。

この方法だと、悪意のあるスクリプトが実行される危険性があります。 例えば、ユーザーが入力したマークダウンに危険なコードが含まれていた場合、そのまま実行されてしまうんです。

React Markdownなら、そういった危険を自動的に取り除いてくれます。 安全で、使いやすくて、カスタマイズもできる。まさに理想的ですね。

React Markdownの主な特徴

React Markdownが選ばれる理由をまとめてみました。

セキュリティ

デフォルトで危険なHTMLタグを除去してくれます。

// 危険なスクリプトも安全に処理
const maliciousMarkdown = `
# 正常なタイトル

<script>alert('XSS攻撃')</script>

通常のテキスト
`;

// React Markdownは<script>タグを除去
<ReactMarkdown>{maliciousMarkdown}</ReactMarkdown>

この例では、悪意のある<script>タグが含まれています。 でも、React Markdownが自動的にそれを取り除いてくれるんです。

「XSS攻撃」というアラートが表示されることはありません。 代わりに、安全なタイトルとテキストだけが表示されます。

高いカスタマイズ性

独自のスタイルやコンポーネントを適用できます。

// カスタムスタイルの適用
<ReactMarkdown
  components={{
    h1: ({ children }) => <h1 className="custom-heading">{children}</h1>,
    p: ({ children }) => <p className="custom-paragraph">{children}</p>
  }}
>
  {markdownContent}
</ReactMarkdown>

この例では、見出し(h1)と段落(p)に独自のCSSクラスを追加しています。 これで、自分好みのデザインにカスタマイズできるんです。

軽量性

必要な機能のみを選択的に使用できます。

# 基本パッケージは軽量
react-markdown: ~47KB (gzipped)

# 追加機能は必要に応じて
remark-gfm: ~15KB (GitHub Flavored Markdown)

基本パッケージはとても軽いです。 追加機能も、必要な分だけインストールできるので無駄がありません。

インストールと基本設定をしてみよう

実際にReact Markdownを使ってみましょう。

パッケージのインストール

まず、必要なパッケージをインストールします。

# 基本パッケージ
npm install react-markdown

# または yarn
yarn add react-markdown

# GitHub Flavored Markdownサポート(オプション)
npm install remark-gfm

# シンタックスハイライト(オプション)
npm install react-syntax-highlighter

基本パッケージのreact-markdownは必須です。 他の2つは、より高度な機能が欲しい時にインストールしてください。

remark-gfmは、GitHubで使われているマークダウンの拡張機能です。 テーブルやタスクリストなどが使えるようになります。

react-syntax-highlighterは、コードブロックをきれいに色分けしてくれます。 プログラミングに関する記事を書く時に便利ですね。

基本的な実装

最もシンプルな使い方から始めましょう。

import ReactMarkdown from 'react-markdown';

function BasicMarkdownExample() {
  const markdown = `
# React Markdownの基本的な使い方

これは**太字**で、これは*斜体*です。

## リスト

- 項目1
- 項目2
- 項目3

## コードブロック

\`\`\`javascript
const greeting = "Hello, React Markdown!";
console.log(greeting);
\`\`\`

## リンク

[React公式サイト](https://react.dev)
`;

  return (
    <div className="markdown-container">
      <ReactMarkdown>{markdown}</ReactMarkdown>
    </div>
  );
}

この実装はとてもシンプルですね。

import ReactMarkdown from 'react-markdown'で、ライブラリを読み込みます。 markdown変数に、表示したいマークダウンの文字列を入れます。

そして、<ReactMarkdown>{markdown}</ReactMarkdown>で囲むだけ。 これだけで、マークダウンがきれいに表示されます。

className="markdown-container"は、後でCSSでスタイリングするために付けています。 なくても動きますが、見た目を整える時に便利です。

外部ファイルからの読み込み

実際のプロジェクトでは、マークダウンファイルを外部から読み込むことが多いでしょう。

import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';

function FileMarkdownExample() {
  const [markdown, setMarkdown] = useState('');
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const loadMarkdown = async () => {
      try {
        const response = await fetch('/content/article.md');
        const text = await response.text();
        setMarkdown(text);
      } catch (error) {
        console.error('マークダウンの読み込みに失敗しました:', error);
        setMarkdown('# エラー

コンテンツの読み込みに失敗しました。');
      } finally {
        setLoading(false);
      }
    };

    loadMarkdown();
  }, []);

  if (loading) {
    return <div>読み込み中...</div>;
  }

  return (
    <div className="markdown-container">
      <ReactMarkdown>{markdown}</ReactMarkdown>
    </div>
  );
}

この実装のポイントを説明しますね。

useStateで2つの状態を管理しています。

  • markdown: 読み込んだマークダウンの内容
  • loading: 読み込み中かどうか

useEffectの中で、fetchを使ってマークダウンファイルを読み込みます。 /content/article.mdは、あなたのプロジェクトに応じて変更してください。

読み込みが成功したらsetMarkdown(text)でマークダウンを設定します。 失敗した場合は、エラーメッセージを表示するマークダウンを設定しています。

最後にsetLoading(false)で、読み込み完了を知らせます。

表示部分では、まず読み込み中かどうかをチェックしています。 読み込み中なら「読み込み中...」を表示し、完了したらマークダウンを表示します。

動的コンテンツの表示

CMSやAPIから取得したコンテンツを表示する場合の実装です。

function DynamicMarkdownExample() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    // APIからブログ記事を取得
    const fetchPosts = async () => {
      try {
        const response = await fetch('/api/posts');
        const data = await response.json();
        setPosts(data);
      } catch (error) {
        console.error('記事の取得に失敗しました:', error);
      }
    };

    fetchPosts();
  }, []);

  return (
    <div className="blog-container">
      {posts.map(post => (
        <article key={post.id} className="blog-post">
          <h1>{post.title}</h1>
          <p className="meta">
            {new Date(post.createdAt).toLocaleDateString()}
          </p>
          <div className="content">
            <ReactMarkdown>{post.content}</ReactMarkdown>
          </div>
        </article>
      ))}
    </div>
  );
}

この例では、複数のブログ記事を表示しています。

/api/postsから記事のリストを取得して、setPosts(data)で状態に保存します。 各記事は{ id, title, createdAt, content }のような形式を想定しています。

表示部分では、posts.map()で各記事をループ処理しています。 タイトルと日付は普通に表示し、本文のpost.contentをReact Markdownで表示しています。

これで、マークダウンで書かれたブログ記事を動的に表示できるようになります。

高度な機能とカスタマイズを試してみよう

React Markdownの強力な機能を活用してみましょう。

GitHub Flavored Markdownサポート

標準的なマークダウンに加えて、GitHubで使用される拡張機能を使用できます。

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

function GfmMarkdownExample() {
  const gfmMarkdown = `
# GitHub Flavored Markdown

## テーブル

| 名前 | 年齢 | 職業 |
|------|------|------|
| 田中 | 30 | エンジニア |
| 佐藤 | 25 | デザイナー |
| 鈴木 | 35 | マネージャー |

## タスクリスト

- [x] 完了済みタスク
- [ ] 未完了タスク
- [ ] 別の未完了タスク

## 打ち消し線

~~この文字は打ち消されています~~

## 自動リンク

https://github.com は自動的にリンクになります。
`;

  return (
    <div className="markdown-container">
      <ReactMarkdown remarkPlugins={[remarkGfm]}>
        {gfmMarkdown}
      </ReactMarkdown>
    </div>
  );
}

GitHub Flavored Markdownの使い方を説明しますね。

まず、import remarkGfm from 'remark-gfm'でプラグインを読み込みます。 そして、remarkPlugins={[remarkGfm]}をReactMarkdownに渡します。

これで、以下の機能が使えるようになります。

テーブル: |で区切って表を作れます。 2行目の|------|は、列の境界を表しています。

タスクリスト: - [x]で完了済み、- [ ]で未完了のチェックボックスが表示されます。

打ち消し線: ~~で囲んだ文字に打ち消し線が引かれます。

自動リンク: URLを書くだけで、自動的にクリックできるリンクになります。

これらの機能で、より豊かな表現ができるようになりますね。

シンタックスハイライト

コードブロックにシンタックスハイライトを適用できます。

import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

function SyntaxHighlightExample() {
  const codeMarkdown = `
# コードサンプル

## JavaScript

\`\`\`javascript
function calculateSum(a, b) {
  return a + b;
}

const result = calculateSum(5, 3);
console.log(result); // 8
\`\`\`

## Python

\`\`\`python
def calculate_sum(a, b):
    return a + b

result = calculate_sum(5, 3)
print(result)  # 8
\`\`\`

## CSS

\`\`\`css
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.heading {
  font-size: 2rem;
  color: #333;
}
\`\`\`
`;

  return (
    <div className="markdown-container">
      <ReactMarkdown
        components={{
          code({ node, inline, className, children, ...props }) {
            const match = /language-(\w+)/.exec(className || '');
            return !inline && match ? (
              <SyntaxHighlighter
                style={vscDarkPlus}
                language={match[1]}
                PreTag="div"
                {...props}
              >
                {String(children).replace(/
$/, '')}
              </SyntaxHighlighter>
            ) : (
              <code className={className} {...props}>
                {children}
              </code>
            );
          }
        }}
      >
        {codeMarkdown}
      </ReactMarkdown>
    </div>
  );
}

シンタックスハイライトの仕組みを詳しく説明しますね。

まず、必要なライブラリを読み込んでいます。

  • SyntaxHighlighter: コードをきれいに色分けしてくれるコンポーネント
  • vscDarkPlus: Visual Studio Codeの暗いテーマ

componentsプロパティで、code要素をカスタマイズしています。 この関数は、マークダウンのコードブロックが見つかると実行されます。

const match = /language-(\w+)/.exec(className || '')で、言語名を取得しています。 マークダウンで```javascriptと書くと、classNamelanguage-javascriptが入ります。

!inline && matchで、インラインコード(文中のcode)ではなく、かつ言語が指定されている場合をチェックしています。

条件に合致すればSyntaxHighlighterを使い、そうでなければ通常の<code>タグを返します。

これで、JavaScript、Python、CSSなどのコードが、それぞれの言語に応じて色分けされて表示されます。

カスタムコンポーネント

独自のコンポーネントでマークダウン要素を置き換えることができます。

import ReactMarkdown from 'react-markdown';

// カスタムコンポーネント
const CustomHeading = ({ level, children }) => {
  const HeadingTag = `h${level}`;
  const id = children.toString().toLowerCase().replace(/\s+/g, '-');
  
  return (
    <HeadingTag id={id} className={`heading-${level}`}>
      {children}
      <a href={`#${id}`} className="anchor-link">
        #
      </a>
    </HeadingTag>
  );
};

const CustomLink = ({ href, children }) => {
  const isExternal = href.startsWith('http');
  
  return (
    <a 
      href={href}
      target={isExternal ? '_blank' : '_self'}
      rel={isExternal ? 'noopener noreferrer' : ''}
      className={isExternal ? 'external-link' : 'internal-link'}
    >
      {children}
      {isExternal && <span className="external-icon">↗</span>}
    </a>
  );
};

const CustomImage = ({ src, alt }) => {
  return (
    <figure className="image-container">
      <img 
        src={src} 
        alt={alt}
        loading="lazy"
        className="responsive-image"
      />
      {alt && <figcaption>{alt}</figcaption>}
    </figure>
  );
};

function CustomComponentExample() {
  const customMarkdown = `
# カスタムコンポーネントの例

## 見出しにアンカーリンクが付きます

### サブセクション

[内部リンク](/about)

[外部リンク](https://react.dev)

![カスタム画像](https://via.placeholder.com/400x200 "画像の説明")
`;

  return (
    <div className="markdown-container">
      <ReactMarkdown
        components={{
          h1: (props) => <CustomHeading level={1} {...props} />,
          h2: (props) => <CustomHeading level={2} {...props} />,
          h3: (props) => <CustomHeading level={3} {...props} />,
          a: CustomLink,
          img: CustomImage,
        }}
      >
        {customMarkdown}
      </ReactMarkdown>
    </div>
  );
}

カスタムコンポーネントの詳細を説明しますね。

CustomHeadingは、見出しにアンカーリンクを追加しています。 idを生成して、「#」リンクをクリックするとその見出しにジャンプできます。 技術文書やブログでよく見る機能ですね。

CustomLinkは、リンクの種類に応じて動作を変えています。 外部リンク(httpで始まる)は新しいタブで開き、矢印アイコンを表示します。 内部リンクは同じタブで開きます。

CustomImageは、画像を<figure>要素で囲んで、キャプション(説明文)を追加しています。 loading="lazy"で遅延読み込みも有効にしています。

componentsプロパティで、これらのカスタムコンポーネントを指定しています。 マークダウンの要素が、自動的にカスタムコンポーネントに置き換わります。

数式の表示

LaTeX形式の数式を表示することも可能です。

import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';

function MathExample() {
  const mathMarkdown = `
# 数式の例

## インライン数式

円の面積は $A = \\pi r^2$ で計算できます。

## ブロック数式

$$
\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}
$$

## 複雑な数式

$$
f(x) = \\begin{cases}
x^2 & \	ext{if } x \\geq 0 \\\\
-x^2 & \	ext{if } x < 0
\\end{cases}
$$
`;

  return (
    <div className="markdown-container">
      <ReactMarkdown
        remarkPlugins={[remarkMath]}
        rehypePlugins={[rehypeKatex]}
      >
        {mathMarkdown}
      </ReactMarkdown>
    </div>
  );
}

数式表示の仕組みを説明しますね。

remarkMathは、マークダウンから数式を認識するプラグインです。 rehypeKatexは、認識した数式をきれいに表示するプラグインです。

インライン数式は$で囲みます(例:$A = \\pi r^2$)。 ブロック数式は$$で囲みます。

LaTeX記法を使って、複雑な数式も表現できます。 理系の記事や数学の解説などで活用できますね。

ただし、これらのプラグインは別途インストールが必要です。

npm install remark-math rehype-katex katex

実践的な使用例を見てみよう

実際のプロジェクトで活用できる具体的な実装例を紹介します。

ブログシステム

完全なブログシステムの実装例です。

import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

function BlogPost({ postId }) {
  const [post, setPost] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchPost = async () => {
      try {
        const response = await fetch(`/api/posts/${postId}`);
        if (!response.ok) {
          throw new Error('記事の取得に失敗しました');
        }
        const data = await response.json();
        setPost(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPost();
  }, [postId]);

  if (loading) return <div className="loading">読み込み中...</div>;
  if (error) return <div className="error">エラー: {error}</div>;
  if (!post) return <div className="not-found">記事が見つかりません</div>;

  return (
    <article className="blog-post">
      <header className="post-header">
        <h1>{post.title}</h1>
        <div className="post-meta">
          <time dateTime={post.createdAt}>
            {new Date(post.createdAt).toLocaleDateString('ja-JP')}
          </time>
          <span className="author">by {post.author}</span>
          <div className="tags">
            {post.tags.map(tag => (
              <span key={tag} className="tag">#{tag}</span>
            ))}
          </div>
        </div>
      </header>

      <div className="post-content">
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          components={{
            code({ node, inline, className, children, ...props }) {
              const match = /language-(\w+)/.exec(className || '');
              return !inline && match ? (
                <SyntaxHighlighter
                  style={vscDarkPlus}
                  language={match[1]}
                  PreTag="div"
                  {...props}
                >
                  {String(children).replace(/
$/, '')}
                </SyntaxHighlighter>
              ) : (
                <code className={className} {...props}>
                  {children}
                </code>
              );
            },
            h2: ({ children }) => {
              const id = children.toString().toLowerCase().replace(/\s+/g, '-');
              return (
                <h2 id={id}>
                  {children}
                  <a href={`#${id}`} className="anchor-link">#</a>
                </h2>
              );
            },
            img: ({ src, alt }) => (
              <figure className="image-figure">
                <img src={src} alt={alt} loading="lazy" />
                {alt && <figcaption>{alt}</figcaption>}
              </figure>
            ),
          }}
        >
          {post.content}
        </ReactMarkdown>
      </div>

      <footer className="post-footer">
        <div className="share-buttons">
          <button onClick={() => sharePost(post)}>
            シェアする
          </button>
        </div>
      </footer>
    </article>
  );
}

// 関連する関数
function sharePost(post) {
  if (navigator.share) {
    navigator.share({
      title: post.title,
      text: post.excerpt,
      url: window.location.href,
    });
  } else {
    // フォールバック処理
    navigator.clipboard.writeText(window.location.href);
    alert('URLをクリップボードにコピーしました');
  }
}

このブログシステムの特徴を説明しますね。

データ取得: postIdに基づいて、APIから記事データを取得しています。 エラーハンドリングもしっかりと実装されています。

メタ情報表示: タイトル、作成日、著者、タグなどの情報を表示しています。 toLocaleDateString('ja-JP')で、日本語形式の日付にしています。

カスタマイズされたマークダウン: シンタックスハイライト、アンカーリンク、画像の最適化など、複数の機能を組み合わせています。

シェア機能: 現代的なWebアプリらしく、記事をシェアする機能も付いています。 navigator.shareをサポートしているブラウザでは、ネイティブのシェア機能を使います。

ドキュメンテーションサイト

技術文書やAPIドキュメントの表示に適した実装です。

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

function DocumentationPage({ section }) {
  const [content, setContent] = useState('');
  const [toc, setToc] = useState([]);

  useEffect(() => {
    const loadContent = async () => {
      const response = await fetch(`/docs/${section}.md`);
      const text = await response.text();
      setContent(text);
      
      // 目次を生成
      const tocItems = generateToc(text);
      setToc(tocItems);
    };

    loadContent();
  }, [section]);

  return (
    <div className="documentation-container">
      <aside className="sidebar">
        <nav className="toc">
          <h3>目次</h3>
          <ul>
            {toc.map((item, index) => (
              <li key={index} className={`toc-level-${item.level}`}>
                <a href={`#${item.id}`}>{item.text}</a>
              </li>
            ))}
          </ul>
        </nav>
      </aside>

      <main className="main-content">
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          components={{
            h1: ({ children }) => {
              const id = generateId(children);
              return <h1 id={id}>{children}</h1>;
            },
            h2: ({ children }) => {
              const id = generateId(children);
              return <h2 id={id}>{children}</h2>;
            },
            h3: ({ children }) => {
              const id = generateId(children);
              return <h3 id={id}>{children}</h3>;
            },
            table: ({ children }) => (
              <div className="table-wrapper">
                <table>{children}</table>
              </div>
            ),
            blockquote: ({ children }) => (
              <blockquote className="callout">
                {children}
              </blockquote>
            ),
          }}
        >
          {content}
        </ReactMarkdown>
      </main>
    </div>
  );
}

// ヘルパー関数
function generateId(children) {
  return children.toString().toLowerCase().replace(/\s+/g, '-');
}

function generateToc(markdown) {
  const lines = markdown.split('
');
  const toc = [];
  
  lines.forEach(line => {
    const match = line.match(/^(#{1,6})\s+(.+)$/);
    if (match) {
      const level = match[1].length;
      const text = match[2];
      const id = generateId(text);
      toc.push({ level, text, id });
    }
  });
  
  return toc;
}

ドキュメンテーションサイトの仕組みを説明しますね。

自動目次生成: generateToc関数で、マークダウンの見出しから自動的に目次を生成しています。 見出しレベル(#の数)に応じて、インデントを変えて表示できます。

サイドバーナビゲーション: 左側に目次を表示して、クリックするとその見出しにジャンプできます。 長いドキュメントでも、簡単にナビゲーションできますね。

テーブルのレスポンシブ対応: テーブルをdivで囲んで、スマートフォンでも見やすくしています。

引用ブロックのカスタマイズ: blockquotecalloutクラスを追加して、注意書きなどを目立たせています。

CMSとの統合

ヘッドレスCMSから取得したコンテンツを表示する実装です。

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

function CmsContentDisplay({ contentId }) {
  const [content, setContent] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchContent = async () => {
      try {
        // Contentful、Strapi、MicroCMS等のAPIを想定
        const response = await fetch(`/api/cms/content/${contentId}`);
        const data = await response.json();
        setContent(data);
      } catch (error) {
        console.error('コンテンツの取得に失敗しました:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchContent();
  }, [contentId]);

  if (loading) return <div>読み込み中...</div>;
  if (!content) return <div>コンテンツが見つかりません</div>;

  return (
    <div className="cms-content">
      <header>
        <h1>{content.title}</h1>
        {content.featuredImage && (
          <img 
            src={content.featuredImage.url} 
            alt={content.featuredImage.alt}
            className="featured-image"
          />
        )}
      </header>

      <div className="content-body">
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          components={{
            // カスタムコンポーネントの適用
            img: ({ src, alt }) => {
              // CDNのURL変換などの処理
              const optimizedSrc = optimizeImageUrl(src);
              return (
                <img 
                  src={optimizedSrc} 
                  alt={alt} 
                  loading="lazy"
                  className="content-image"
                />
              );
            },
          }}
        >
          {content.body}
        </ReactMarkdown>
      </div>
    </div>
  );
}

function optimizeImageUrl(url) {
  // 画像の最適化処理(例:Cloudinary、ImageKit等)
  return url.replace(/\?.*$/, '?w=800&h=600&fit=crop&auto=format');
}

CMS統合の特徴を説明しますね。

ヘッドレスCMSとの連携: Contentful、Strapi、MicroCMSなど、人気のヘッドレスCMSと連携できます。 APIからコンテンツを取得して、React Markdownで表示します。

画像の最適化: optimizeImageUrl関数で、画像URLにクエリパラメータを追加しています。 CDNサービスを使って、適切なサイズに自動リサイズできます。

アイキャッチ画像: content.featuredImageで、記事のメイン画像を表示しています。 CMSで設定したメタデータも活用できますね。

これで、非技術者でもCMSから簡単にコンテンツを更新できるようになります。

パフォーマンス最適化をしてみよう

React Markdownのパフォーマンスを最適化するテクニックを紹介します。

メモ化の活用

不要な再レンダリングを防ぐためにメモ化を活用します。

import { memo, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

const MemoizedMarkdown = memo(({ children, ...props }) => {
  return (
    <ReactMarkdown {...props}>
      {children}
    </ReactMarkdown>
  );
});

function OptimizedMarkdownDisplay({ content, title }) {
  // マークダウンの処理をメモ化
  const processedContent = useMemo(() => {
    return content.replace(/{{title}}/g, title);
  }, [content, title]);

  // remarkプラグインもメモ化
  const remarkPlugins = useMemo(() => [remarkGfm], []);

  return (
    <div className="markdown-container">
      <MemoizedMarkdown remarkPlugins={remarkPlugins}>
        {processedContent}
      </MemoizedMarkdown>
    </div>
  );
}

メモ化の効果を説明しますね。

MemoizedMarkdown: memoでラップすることで、propsが変わらない限り再レンダリングされません。 マークダウンの内容が同じなら、無駄な処理をスキップできます。

processedContent: useMemoで、コンテンツの前処理をメモ化しています。 contenttitleが変わらない限り、同じ結果を返します。

remarkPlugins: プラグインの配列もuseMemoでメモ化しています。 毎回新しい配列を作らないので、余計な再処理を防げます。

遅延読み込み

大きなマークダウンファイルは遅延読み込みで最適化します。

import { lazy, Suspense } from 'react';

// Markdownコンポーネントの遅延読み込み
const LazyMarkdown = lazy(() => import('./MarkdownRenderer'));

function LazyLoadingExample() {
  return (
    <div className="content-container">
      <Suspense fallback={<div>コンテンツを読み込み中...</div>}>
        <LazyMarkdown />
      </Suspense>
    </div>
  );
}

// 別ファイル: MarkdownRenderer.jsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

export default function MarkdownRenderer({ content }) {
  return (
    <ReactMarkdown remarkPlugins={[remarkGfm]}>
      {content}
    </ReactMarkdown>
  );
}

遅延読み込みの仕組みを説明しますね。

lazyを使って、マークダウンコンポーネントを必要な時だけ読み込みます。 これで、初期ページ読み込み時のバンドルサイズを小さくできます。

Suspenseで、読み込み中の表示を制御しています。 「コンテンツを読み込み中...」が表示された後、実際のマークダウンが表示されます。

大きなマークダウンライブラリや、シンタックスハイライターは重いので、この方法が効果的です。

画像の最適化

マークダウン内の画像を最適化します。

function OptimizedImageMarkdown({ content }) {
  const optimizedComponents = useMemo(() => ({
    img: ({ src, alt }) => {
      const [loading, setLoading] = useState(true);
      const [error, setError] = useState(false);

      return (
        <div className="image-container">
          {loading && <div className="image-placeholder">読み込み中...</div>}
          <img
            src={src}
            alt={alt}
            loading="lazy"
            onLoad={() => setLoading(false)}
            onError={() => {
              setError(true);
              setLoading(false);
            }}
            style={{ display: loading ? 'none' : 'block' }}
          />
          {error && <div className="image-error">画像の読み込みに失敗しました</div>}
        </div>
      );
    },
  }), []);

  return (
    <ReactMarkdown components={optimizedComponents}>
      {content}
    </ReactMarkdown>
  );
}

画像最適化の仕組みを説明しますね。

遅延読み込み: loading="lazy"で、画像が見える直前まで読み込みを遅らせます。 ページの初期表示が速くなります。

読み込み状態の管理: loadingerrorの状態を管理して、適切なフィードバックを表示します。

エラーハンドリング: 画像の読み込みに失敗した時も、エラーメッセージを表示します。 画像が見つからない時でも、レイアウトが崩れません。

プレースホルダー: 読み込み中は「読み込み中...」を表示して、ユーザーに状況を伝えます。

セキュリティ対策も忘れずに

React Markdownを安全に使用するためのセキュリティ対策を解説します。

危険なHTMLの除去

デフォルトの設定でもある程度安全ですが、より厳密な制御が可能です。

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

function SecureMarkdown({ content }) {
  // 許可するHTMLタグを制限
  const allowedElements = [
    'p', 'br', 'strong', 'em', 'u', 'code', 'pre',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'ul', 'ol', 'li', 'blockquote',
    'table', 'thead', 'tbody', 'tr', 'td', 'th',
    'img', 'a'
  ];

  // 危険な属性を除去
  const sanitizeProps = (props, tagName) => {
    const allowedProps = {
      img: ['src', 'alt', 'title'],
      a: ['href', 'title'],
      // 他のタグに対する許可属性
    };

    if (allowedProps[tagName]) {
      return Object.keys(props)
        .filter(key => allowedProps[tagName].includes(key))
        .reduce((obj, key) => {
          obj[key] = props[key];
          return obj;
        }, {});
    }

    return {};
  };

  const secureComponents = {
    img: ({ src, alt, title, ...props }) => {
      // 外部画像URLの検証
      const isValidUrl = (url) => {
        try {
          const urlObj = new URL(url);
          return ['http:', 'https:'].includes(urlObj.protocol);
        } catch {
          return false;
        }
      };

      if (!isValidUrl(src)) {
        return <div className="invalid-image">無効な画像URL</div>;
      }

      return (
        <img
          src={src}
          alt={alt}
          title={title}
          referrerPolicy="no-referrer"
          {...sanitizeProps(props, 'img')}
        />
      );
    },
    a: ({ href, children, ...props }) => {
      // 外部リンクの安全な処理
      const isExternalLink = href?.startsWith('http');
      
      return (
        <a
          href={href}
          target={isExternalLink ? '_blank' : '_self'}
          rel={isExternalLink ? 'noopener noreferrer' : ''}
          {...sanitizeProps(props, 'a')}
        >
          {children}
        </a>
      );
    },
  };

  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      allowedElements={allowedElements}
      components={secureComponents}
    >
      {content}
    </ReactMarkdown>
  );
}

セキュリティ対策の詳細を説明しますね。

許可タグの制限: allowedElementsで、表示を許可するHTMLタグを明示的に指定しています。 scriptiframeなど、危険なタグは含まれていません。

属性のサニタイズ: sanitizeProps関数で、各タグに許可される属性を制限しています。 例えば、画像にはsrcalttitleだけを許可しています。

URL検証: isValidUrl関数で、画像のURLが有効かどうかをチェックしています。 javascript:data:などの危険なURLはブロックされます。

外部リンクの安全な処理: 外部リンクにはnoopener noreferrerを追加して、セキュリティホールを防いでいます。

入力値の検証

ユーザーからの入力を受け取る場合の検証機能です。

function UserContentMarkdown({ userContent }) {
  const [sanitizedContent, setSanitizedContent] = useState('');

  useEffect(() => {
    const sanitizeContent = (content) => {
      // 基本的な検証
      if (!content || typeof content !== 'string') {
        return '';
      }

      // 最大文字数制限
      if (content.length > 50000) {
        return '# エラー

コンテンツが長すぎます。';
      }

      // 危険なパターンの除去
      const dangerousPatterns = [
        /<script[^>]*>.*?<\/script>/gi,
        /<iframe[^>]*>.*?<\/iframe>/gi,
        /javascript:/gi,
        /vbscript:/gi,
        /data:text\/html/gi,
      ];

      let sanitized = content;
      dangerousPatterns.forEach(pattern => {
        sanitized = sanitized.replace(pattern, '');
      });

      return sanitized;
    };

    setSanitizedContent(sanitizeContent(userContent));
  }, [userContent]);

  return (
    <div className="user-content">
      <ReactMarkdown>{sanitizedContent}</ReactMarkdown>
    </div>
  );
}

入力値検証の仕組みを説明しますね。

基本検証: 入力値が文字列かどうか、存在するかどうかをチェックしています。 不正な値が渡されても、エラーにならないように守られています。

文字数制限: 50,000文字の制限を設けています。 あまりに長いコンテンツは、パフォーマンスやセキュリティの問題を引き起こす可能性があります。

危険パターンの除去: 正規表現で、明らかに危険なパターンを除去しています。 <script>タグやjavascript:URLなどがブロックされます。

これらの対策で、ユーザー生成コンテンツも安全に表示できるようになります。

スタイリングをしてみよう

React Markdownで生成されるHTMLをスタイリングする方法を紹介します。

基本的なCSS

マークダウンコンテンツのスタイリング例です。

.markdown-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  line-height: 1.6;
  color: #333;
}

.markdown-container h1 {
  font-size: 2.5rem;
  margin: 2rem 0 1rem;
  border-bottom: 2px solid #eee;
  padding-bottom: 0.5rem;
}

.markdown-container h2 {
  font-size: 2rem;
  margin: 1.5rem 0 1rem;
  color: #2c3e50;
}

.markdown-container h3 {
  font-size: 1.5rem;
  margin: 1rem 0 0.5rem;
  color: #34495e;
}

.markdown-container p {
  margin: 1rem 0;
  text-align: justify;
}

.markdown-container blockquote {
  border-left: 4px solid #3498db;
  padding-left: 1rem;
  margin: 1rem 0;
  background-color: #f8f9fa;
  font-style: italic;
}

.markdown-container ul,
.markdown-container ol {
  margin: 1rem 0;
  padding-left: 2rem;
}

.markdown-container li {
  margin: 0.5rem 0;
}

.markdown-container code {
  background-color: #f1f2f6;
  padding: 0.2rem 0.4rem;
  border-radius: 4px;
  font-family: 'Courier New', monospace;
  font-size: 0.9rem;
}

.markdown-container pre {
  background-color: #2c3e50;
  color: #ecf0f1;
  padding: 1rem;
  border-radius: 8px;
  overflow-x: auto;
  margin: 1rem 0;
}

.markdown-container pre code {
  background-color: transparent;
  padding: 0;
  color: inherit;
}

.markdown-container table {
  width: 100%;
  border-collapse: collapse;
  margin: 1rem 0;
}

.markdown-container th,
.markdown-container td {
  border: 1px solid #ddd;
  padding: 0.75rem;
  text-align: left;
}

.markdown-container th {
  background-color: #f8f9fa;
  font-weight: bold;
}

.markdown-container img {
  max-width: 100%;
  height: auto;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.markdown-container a {
  color: #3498db;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: border-bottom-color 0.2s;
}

.markdown-container a:hover {
  border-bottom-color: #3498db;
}

/* レスポンシブデザイン */
@media (max-width: 768px) {
  .markdown-container {
    padding: 10px;
  }
  
  .markdown-container h1 {
    font-size: 2rem;
  }
  
  .markdown-container h2 {
    font-size: 1.5rem;
  }
}

このCSSの特徴を説明しますね。

全体のレイアウト: 最大幅800pxで中央配置し、読みやすい行間を設定しています。 フォントは、システムデフォルトを使って統一感を出しています。

見出しの階層: H1〜H3それぞれに適切なサイズと色を設定しています。 H1には下線を追加して、記事タイトルらしい見た目にしています。

引用ブロック: 左に青い線を入れて、背景色を変えることで目立たせています。 引用であることが一目で分かりますね。

コードの表示: インラインコードは薄いグレーの背景、ブロックコードは暗い背景にしています。 読みやすさとコントラストを意識しています。

テーブルのスタイル: 境界線と背景色で、データが読みやすくなるように工夫しています。

レスポンシブ対応: スマートフォンでも読みやすいように、フォントサイズとパディングを調整しています。

CSS-in-JSでのスタイリング

styled-componentsを使用したスタイリング例です。

import styled from 'styled-components';
import ReactMarkdown from 'react-markdown';

const StyledMarkdown = styled(ReactMarkdown)`
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
  
  h1 {
    font-size: 2.5rem;
    margin: 2rem 0 1rem;
    border-bottom: 2px solid #eee;
    padding-bottom: 0.5rem;
  }
  
  h2 {
    font-size: 2rem;
    margin: 1.5rem 0 1rem;
    color: #2c3e50;
  }
  
  blockquote {
    border-left: 4px solid #3498db;
    padding-left: 1rem;
    margin: 1rem 0;
    background-color: #f8f9fa;
    font-style: italic;
  }
  
  code {
    background-color: #f1f2f6;
    padding: 0.2rem 0.4rem;
    border-radius: 4px;
    font-family: 'Courier New', monospace;
  }
  
  pre {
    background-color: #2c3e50;
    color: #ecf0f1;
    padding: 1rem;
    border-radius: 8px;
    overflow-x: auto;
  }
`;

function StyledMarkdownExample({ content }) {
  return (
    <StyledMarkdown>
      {content}
    </StyledMarkdown>
  );
}

CSS-in-JSの利点を説明しますね。

コンポーネントスコープ: スタイルがコンポーネントに閉じているので、他の要素に影響しません。

動的スタイリング: JavaScriptの変数を使って、動的にスタイルを変更できます。

型安全性: TypeScriptを使えば、プロパティの型チェックも効きます。

通常のCSSとCSS-in-JS、どちらもそれぞれの良さがあります。 プロジェクトの方針に合わせて選んでくださいね。

まとめ:マークダウンを活用しよう

React Markdownを使うことで、マークダウンコンテンツを簡単かつ安全に表示できます。

主要なメリット

React Markdownの導入によって得られる主なメリットをまとめますね。

  • 簡単な実装: 数行のコードでマークダウンを表示
  • 高いセキュリティ: 危険なHTMLタグを自動除去
  • 豊富なカスタマイズ: 独自のスタイルやコンポーネント
  • パフォーマンス: 最適化された軽量ライブラリ
  • 拡張性: プラグインによる機能拡張

これらのメリットで、開発効率が大幅に向上します。

実装時のポイント

実際にプロジェクトに導入する際は、以下のポイントを意識しましょう。

// 基本的な使用方法
import ReactMarkdown from 'react-markdown';

// 必要に応じて機能を追加
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';

// セキュリティを考慮したコンポーネント設計
const SecureMarkdown = ({ content }) => {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        // カスタムコンポーネント
      }}
    >
      {content}
    </ReactMarkdown>
  );
};

段階的に機能を追加していくのがおすすめです。 まずは基本機能から始めて、必要に応じてカスタマイズしていきましょう。

活用シーン

React Markdownが特に有効な場面を整理します。

  • ブログやCMSサイト: 記事コンテンツの表示
  • ドキュメンテーション: API文書や技術資料
  • README表示: GitHubライクなマークダウン表示
  • ユーザー生成コンテンツ: 安全な入力内容の表示
  • 静的サイト生成: Markdown → HTML変換

どの場面でも、React Markdownが力を発揮してくれます。

今日から始めてみよう

React Markdownは、現代のReact開発において非常に実用的なライブラリです。 シンプルな実装から高度なカスタマイズまで幅広く対応できるため、様々なプロジェクトで活用できます。

ぜひ今回紹介した実装方法を参考にして、あなたのプロジェクトでマークダウンコンテンツを効果的に活用してください。

きっと、「こんなに簡単にマークダウンが表示できるなんて!」と驚くはずです。 今日から、React Markdownを使ってみませんか?

関連記事