DEV Community

loading...
Cover image for How to Safely Render Markdown From a React Component

How to Safely Render Markdown From a React Component

OpenReplay Tech Blog
Tech blog for Asayer.io. Quality content by developers for developers interested in JavaScript and related front-end technologies.
Originally published at blog.openreplay.com ・15 min read

by author Fortune Ikechi

React-markdown is a library that provides the React component to render the Markdown markup. Built on remark, a markdown preprocessor.

With react-markdown, you can safely render markdown and not have to rely on the dangerouslySetInnerHTML prop, instead React markdown uses a syntax tree to build a virtual DOM for your markdown.

Markdown is a lightweight markup language that is easy to learn and work with it, created to include basic tags, it helps to create editorial content easily. However, its important to note that React markdown does not support HTML by default and therefore prevents script injection, making it safer to use.

Script injection is a security vunerability that allows malicious code to the user interface element of an application, you can read more about it here.

Why use React markdown

There are many markdown component libraries available for use in React applications, some of which include react-markdown-editor-lite, commonmark-react-renderer and react-remarkable. These libraries although great all focus on dangerouslySetInnerHTML, however react-markdown uses a syntax tree to build the virtual DOM that allows for updating only the changing DOM instead of completely overwriting it. React markdown also supports CommonMark and has extensions to support custom syntax and a list of plugins to extend it’s features.

React markdown vs remark
react-markdown is a library that provides the React component to render the Markdown markup while remark is a markdown preprocessor built on micromark. It inspects, parses and transforms markdowns.

Getting Started with React markdown

In this section, we will build a markdown blog using react markdown, with this application, users can write an article in markdown which when completed can be previewed in plain text. First, let’s create a new project and install react-markdown

Initializing Project

To initialize a new react typescript project, run the command below

npx create-react-app react-markdown-blog --template typescript
Enter fullscreen mode Exit fullscreen mode

In the code above, we initialized a new react typescript project with the application name “react-markdown-app”, this can be replaced with whatever name you choose.

Next, let’s install dependencies and start our application’s development server below

yarn add autoprefixer postcss-cli postcss tailwindcss moment react-markdown
Enter fullscreen mode Exit fullscreen mode

In the code above, we installed the following dependencies, postcss-cli, postcss for transforming styles with JS plugins and help us lint our CSS and tailwindcss for our styling, react-markdown for rendering our markdown component, and moment for parsing our dates, autoprefixer for adding vendor prefixes to our CSS rules.

Next, we need to setup tailwindcss for our project, type the following command in your terminal to generate a tailwind config file in your project directory.

npx tailwind init tailwind.config.js
Enter fullscreen mode Exit fullscreen mode

By default tailwindcss prevents markdown styles from displaying. To fix that, install a tailwind plugin @tailwindcss/typography according to documentation,

The @tailwindcss/typography plugin adds a set of customizable prose classes that you can use to add beautiful typographic defaults to any vanilla HTML, like the output you'd get after parsing some Markdown, or content you pull from a CMS.

Inside the @tailwindcss.config.js file, add the code below

module.exports = {
  purge: [],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {
      typography: {
        DEFAULT: {
          css: {
            color: "#FFF",
            a: {
              color: "#4798C5",
              "&:hover": {
                color: "#2c5282",
              },
            },
            h1: {
              color: "#FFF",
            },
            h2: {
              color: "#FFF",
            },
            h3: {
              color: "#FFF",
            },
            h4: { color: "#FFF" },
            em: { color: "#FFF" },
            strong: { color: "#FFF" },
            blockquote: { color: "#FFF" },
            code: { backgroundColor: "#1A1E22", color: "#FFF" },
          },
        },
      },
    },
  },
  variants: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};
Enter fullscreen mode Exit fullscreen mode

Run the below command to generate a postcss config file in your project directory

touch postcss.config.js
Enter fullscreen mode Exit fullscreen mode

Add the code below in the file;

const tailwindcss = require('tailwindcss');
module.exports = {
    plugins: [
        tailwindcss('./tailwind.config.js'),
        require('autoprefixer')
    ],
};
Enter fullscreen mode Exit fullscreen mode

Create a folder called styles in src and add 2 files main.css (where generated tailwind styles will enter) and tailwind.css for tailwind imports.

Inside tailwind.css, add the codes below

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
Enter fullscreen mode Exit fullscreen mode

Update your scripts in package.json to build tailwind styles when the dev server starts

"scripts": {
  "start": "npm run watch:css && react-scripts start",
  "build": "npm run watch:css && react-scripts build",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "watch:css": "postcss src/styles/tailwind.css -o src/styles/main.css"
}
Enter fullscreen mode Exit fullscreen mode

Lastly, add main.css to src/index.ts, the file should like this;

import React from "react";
import ReactDOM from "react-dom";
import "./styles/main.css";
import "./index.css";
import App from "./App";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);
Enter fullscreen mode Exit fullscreen mode

To start our development server, let’s start our development server using the command below

yarn start
Enter fullscreen mode Exit fullscreen mode

OR using NPM

npm start 
Enter fullscreen mode Exit fullscreen mode

Building Navbar Component

Here, we will create components for our applications, first navigate to your src folder and inside it, create a new folder named components, this will house all of our project components. Inside your components folder, create a new directory called Navbar, inside this directory, create a new file called Navbar.tsx.

Firstly, add react-router-dom for navigation within the app and two react-markdown plugins to extends react-markdown features; we’ll be passing them to the Markdown component.

yarn add react-router-dom remark-gfm rehype-raw
Enter fullscreen mode Exit fullscreen mode

Inside this file, let’s create our Navbar with the code block below:

import { Link, NavLink } from "react-router-dom";
const Navbar = () => {
    return (
        <nav className="mt-6 w-10/12 mx-auto flex justify-between items-center">
            <Link
                style={{ fontStyle: "oblique" }}
                className="text-xl tracking-widest logo"
                to="/"
            >
                Markdown Blog
            </Link>
            <div className="flex-items-center">
                <NavLink
                    activeClassName="border-b-2"
                    className="mr-6 tracking-wider"
                    to="/write-article"
                    exact
                >
                    Write An Article
                </NavLink>
                <NavLink
                    activeClassName="border-b-2"
                    className="tracking-wider"
                    to="/profile"
                    exact
                >
                    Profile
                </NavLink>
            </div>
        </nav>
    );
};
export default Navbar;
Enter fullscreen mode Exit fullscreen mode

In the code blog above, we created a function component called Navbar, inside it we created a Navbar component, giving our application a title, we also added links to a Profile page and to a page where user can write an article.

Next, we will create a blog card that will feature our blog posts, this will show each blog post written and we’d use react-markdown to render the articles in plain text with styles. Let’s do that in the section below.

Adding Helper functions

Before we build our BlogCard, we need to add some helperFunctions that will help us:

  1. Delete, edit, save and fetch posts from localStorage (our DB).
  2. Format the date of the post.
  3. Truncate the post body so our BlogCard doesn’t get too big when the body is very lengthy.

Firstly, inside the src folder create a folder called utils, in there create a new file and name it helperFunctions.ts add the code block below.

import moment from "moment";

export const truncateText = (text: string, maxNum: number) => {
  const textLength = text.length;
  return textLength > maxNum ? `${text.slice(0, maxNum)}...` : text;
};

export const formatDate = (date: Date) => {
  return moment(date).format("Do MMM YYYY, h:mm:ss a");
};
Enter fullscreen mode Exit fullscreen mode

Above, we have a function called textTruncate that accepts in two arguments; text and maxNum.
it checks if the text has a length greater than the maxNumber we want to display, if yes we remove the surplus and add ‘…’ else we return the text.

The second function; formatDate basically formats a date object passed to it to the format we want to display in our app. Let’s add more functions to manipulate our posts with below.

Create another file inside utils and name it server.ts it will contain the functions to add, delete, fetch and edit a post. Add the code below

import { IBlogPost } from "../components/BlogCard/BlogCard";

export const savePost = (post: Partial<IBlogPost>) => {
  if (!localStorage.getItem("markdown-blog")) {
    localStorage.setItem("markdown-blog", JSON.stringify([post]));
  } else {
    const posts = JSON.parse(localStorage.getItem("markdown-blog") as string);
    localStorage.setItem("markdown-blog", JSON.stringify([post, ...posts]));
  }
};

export const editPost = (newPostContent: IBlogPost) => {
  const posts: IBlogPost[] = JSON.parse(
    localStorage.getItem("markdown-blog") as string
  );
  const postIdx = posts.findIndex((post) => post.id === newPostContent.id);
  posts.splice(postIdx, 1, newPostContent);
  localStorage.setItem("markdown-blog", JSON.stringify(posts));
};
Enter fullscreen mode Exit fullscreen mode

In the code we added above, savePost functions takes in an object of type BlogPost which we will create in our BlogCard component. It checks if we have saved any post to our browser’s localStorage from this app with the key markdown-blog, if **none is found we add the post to an array and save it to localStorage. Otherwise we fetch the posts we already have and include it before saving.

We have also added a function called editPost which we will use to edit posts, it takes in the newContent object which will contain updated post properties, in here we fetch all posts, find the index of the post we want to edit and splice it out and replace it with the newContent at that index in the array as seen in line 34. after splicing we save it back to locatStorage.

Let’s add the other two functions below.

export const getPosts = () => {
  if (!localStorage.getItem("markdown-blog")) {
    return [];
  } else {
    const posts = JSON.parse(localStorage.getItem("markdown-blog") as string);
    return posts;
  }
};

export const deletePost = (id: string) => {
  const posts: IBlogPost[] = JSON.parse(
    localStorage.getItem("markdown-blog") as string
  );
  const newPostList = posts.filter((post) => post.id !== id);
  localStorage.setItem("markdown-blog", JSON.stringify(newPostList));
};
Enter fullscreen mode Exit fullscreen mode

getPost functions fetches our posts from localStorage and returns them if we have that post in localStorage or an empty array if we don’t.

deletePost takes in an id as an argument, fetches the all posts from loalStorage, filters out the one with the id passed to this function and saves the rest to localStorage.

Building Blog Card component

As we have now added our helper functions, let’s create a blog card component for our application, to do this we’d first create a typescript interface (which we will pass to our savePost and editPost functions created above) to define the type of each prop to be passed to the BlogCard component, next we will create our BlogCard component, let’s do that below

import { Link } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { formatDate, truncateText } from "../../utils/helperFunctions";
import { deletePost } from "../../utils/server";

export interface IBlogPost {
    id: string;
    title: string;
    url: string;
    date: Date;
    body: string;
    refresh?: () => void;
}
const BlogCard: React.FC<IBlogPost> = ({
    id,
    title,
    body,
    url,
    date,
    refresh,
}) => {
    const formattedDate = formatDate(date);
    const content = truncateText(body, 250);
    const handleDelete = () => {
        const yes = window.confirm("Are you sure you want to delete this article?");
        yes && deletePost(id);
        refresh && refresh();
    };
Enter fullscreen mode Exit fullscreen mode

In the code above, we created an interface for our BlogCard component, imported React-Markdown and the plugins we installed earlier and also helper functions to delete the post, format the post date and truncate the post text.

  • gfm is a remark plugin that adds support for strikethrough, table, tasklist and URLs
  • rehypeRaw makes react-markdown parse html incase we pass html elements inbetween markdown text. This is dangerous and usually not adviceable as it defeats the purpose of react-markdown not rerendering html to prevent html injection but for the purpose of of learning we will use it.

Next, let’s complete our component with the code below:

return (
    <section
        style={{ borderColor: "#bbb" }}
        className="border rounded-md p-4 relative"
    >
        <div className="controls flex absolute right-4 top-3">
            <Link
                title="Edit this article"
                className="block mr-5"
                to={`/edit-article/${id}`}
            >
                <i className="far fa-edit" />
            </Link>
            <span
                role="button"
                onClick={handleDelete}
                title="Delete this article"
                className="block"
            >
                <i className="fas fa-trash hover:text-red-700" />
            </span>
        </div>
        <h3 className="text-3xl font-bold mb-3">{title}</h3>
        <div className="opacity-80">
            <ReactMarkdown
                remarkPlugins={[gfm]}
                rehypePlugins={[rehypeRaw]}
                className="prose"
                children={content}
            />
            &nbsp;
            <Link
                className="text-blue-500 text-sm underline hover:opacity-80"
                to={`${url}`}
            >
                Read more
            </Link>
        </div>
        <p className="mt-4">{formattedDate}</p>
    </section>
);
};
export default BlogCard;
Enter fullscreen mode Exit fullscreen mode

Note that the blogpost body we want to render with react-markdown is passed to the children prop and the other plugins added. The className prose is from tailwind, we get it from the tailwindcss/typography plugin we installed and added to our tailwind config to provide support for markdown styles.

We will add a fontawesome CDN link so we can use fontawesome icons for delete and edit as we have in lines 12 and 13 of the bove code block.

Navigate to your index.html file in public folder and replace the file’s content with the code block below.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="Markdown Blog"
      content="A markdown blog built with React, TS and react-markdown"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!-- Fontawesome CDN link here -->
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css"
      integrity="sha512-iBBXm8fW90+nuLcSKlbmrPcLa0OT92xO1BIsZ+ywDWZCvqsWgccV3gFoRBv0z+8dLJgyAHIhR35VZc2oM/gI1w=="
      crossorigin="anonymous"
      referrerpolicy="no-referrer" />
      <!-- End of Fontawesome CDN link here -->
    <title>Markdown blog</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This Card will preview each post, give a link to the user to view all contents of the post and from here a user can delete a post or navigate to edit-post page. Now that we’ve created this component, we will go ahead to create pages for our application using these components.

Building Home Page

The Home page will contain all the posts in the app, we will also add a ‘no data’ state for when there are no posts to show. To do this, first create a new folder called pages in our project src file, and inside it create another directory called home, in here create a new file called index.tsx.

// src/pages/home/index.tsx

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import BlogCard, { IBlogPost } from "../../components/BlogCard/BlogCard";
import { getPosts } from "../../utils/server";

const Homepage = () => {
    const [articles, setArticles] = useState<IBlogPost[]>([]);
    const [refresh, setRefresh] = useState(false);

    useEffect(() => {
        const posts = getPosts();
        setArticles(posts);
    }, [refresh]);
Enter fullscreen mode Exit fullscreen mode

In the code above, we imported the useEffect and useState from react, next we imported Link from react-router this will help us navigate to write an article page. We also imported our BlogCard component alongside IBlogPost interface, to render out each post from the array of posts. Finally, we imported the getPosts object from our utils directory.

Inside the useEffect hook, we are fetching all posts we have saved to localStorage and adding the array of posts to our component state (useState), we also created a state which will enable us to make the useEffect refetch posts whenever a post is deleted.

Next, let’s render our Home page using the code block below:

return (
        <div className="mt-8 mb-20 w-3/5 mx-auto">
            <h1 className="mb-6 text-xl">Welcome back, Fortune</h1>
            <section className="articles mt-4">
                {articles?.length ? (
                    articles.map((article) => (
                        <article key={article?.id} className="my-4">
                            <BlogCard
                                title={article?.title}
                                id={article?.id}
                                url={`/article/${article?.id}`}
                                body={article?.body}
                                date={article?.date}
                                refresh={() => setRefresh(!refresh)}
                            />
                        </article>
                    ))
                ) : (
                    <div className="mt-20 flex flex-col items-center justify-center">
                        <h2 className="text-2xl">No article right now.</h2>
                        <Link className="block text-blue-500 underline text-sm mt-6" to="/write-article">Add article</Link>
                    </div>
                )}
            </section>
        </div>
    );
};

export default Homepage;
Enter fullscreen mode Exit fullscreen mode

In the code block above, we rendered our Home page with styles using tailwind classes, we mapped through the array of posts and rendered them with our BlogCard component and also added a no data state incase there are posts to render.

Building Post Page

In this section, we will create a page for a user to post a new blog post. Inside your pages directory, create a new folder called post and inside it create a new file named index.tsx, add the code block below:

import { useParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import gfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import { formatDate } from "../../utils/helperFunctions";
import { getPosts } from "../../utils/server";
import { IBlogPost } from "../../components/BlogCard/BlogCard";
Enter fullscreen mode Exit fullscreen mode

We have now imported different components we’d need for building our page. Next, we will be getting an id param from our url with useParams, rendering our post body with ReactMarkdown. let’s do that in the code block below

    const Blog = () => {
        const { id } = useParams<{ id: string }>();

        const post = getPosts().find((post: IBlogPost) => post.id === id);
Enter fullscreen mode Exit fullscreen mode

In the code block above, we created a Blog component and added retrieving the post id from the url to get the post from all posts (line 4). Next, we will render our post content below

return (
        <div className="w-4/5 mx-auto mt-16 mb-24">
            {post ? (
                <>
                    <header
                        style={{ background: "#1C313A" }}
                        className="rounded-md mb-10 max-w-9/12 py-12 px-20"
                    >
                        <h1 className="text-2xl text-center font-semibold uppercase">
                            {post?.title}
                        </h1>
                        <p className="mt-4 text-sm text-center">{formatDate(post?.date as Date)}</p>
                    </header>

                    <ReactMarkdown
                      className="prose"
                      remarkPlugins={[gfm]}
                      rehypePlugins={[rehypeRaw]}
                      children={post?.body as string}
                    />
                </>
            ) : (
                <h3>Post not found!</h3>
            )}
        </div>
    );
};
export default Blog;
Enter fullscreen mode Exit fullscreen mode

In the code above, we are checking if a post with the id passed to the url is found, if found we show the post title, date and render the text with ReactMarkdown. if the post is not found we render ‘post not found’. If done correctly, your application should look like the image below

rendered text

Open Source Session Replay

Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Adding Write-A-Post Page

In this section, we will create a page for writing and editing a post, To do this, create a new folder write inside your pages folder and inside it create a index.tsx file and add the code snippet below

import { useEffect } from "react";
import { useState } from "react";
import { useHistory, useParams } from "react-router";
import { editPost, getPosts, savePost } from "../../utils/server";
import { IBlogPost } from "../../components/BlogCard/BlogCard";

const WriteAnArticle = () => {
    const { id } = useParams<{ id: string }>();
    const history = useHistory();
    const [title, setTitle] = useState("");
    const [body, setBody] = useState("");

    useEffect(() => {
      if (id) {
          const post = getPosts().find((post: IBlogPost) => post.id === id);
          post && setTitle(post.title);
          post && setBody(post.body);
      }
    }, [id]);
Enter fullscreen mode Exit fullscreen mode

This component handles both editing and adding posts, so in case of edit we use the id of the post which we will get with useParams from the url of the page. In the code block above, we initializing a history variable from useHistory with which we will navigate the user to the home page after editing or adding new posts. We also have state to hold our post title and body from inputs.

In the useEffect, if there’s an id in the URL we’re getting the post to be editted from the array of posts and setting it to our component states so that the values show up in our inputs.

Next, we will create a submitHandler function to enable the user to submit a post or save the edits performed.

const submitHandler = (e: { preventDefault: () => void }) => {
        e.preventDefault();

        const post = getPosts().find((post: IBlogPost) => post.id === id);
        if (!id) {
            const post = {
                title,
                body,
                date: new Date(),
                id: new Date().getTime().toString(),
            };
            savePost(post);
        } else if (id && post) {
            const updatedPost = {
                ...post,
                title,
                body,
            };
           editPost(updatedPost);
        }
        history.push("/");
    };
Enter fullscreen mode Exit fullscreen mode

In the code above, we are handling form submit, if the id deosn’t exist we save a new post with savePost helper function, using the title and body from the form and the id and date as the current timestamp of that moment. else we update the post title, body.

We will render our component body below;

return (
        <div className="w-3/5 mx-auto mt-12 mb-28">
            <h3 className="text-3xl text-center capitalize mb-10 tracking-widest">
                Write a post for your blog from here
            </h3>
            <form onSubmit={submitHandler} className="w-10/12 mx-auto">
                <input
                    className="w-full px-4 mb-6 block rounded-md"
                    type="text"
                    value={title}
                    onChange={(e) => setTitle(e.target.value)}
                    placeholder="Enter article title"
                />
                <textarea
                    className="w-full px-4 pt-4 block rounded-md"
                    name="post-body"
                    id="post-body"
                    value={body}
                    onChange={(e) => setBody(e.target.value)}
                    placeholder="Enter article body. you can use markdown syntax!"
                />
                <button
                    title={!body || !title ? "Fill form completely" : "Submit form"}
                    disabled={!body || !title}
                    className="block rounded mt-8"
                    type="submit"
                >
                    Submit Post
                </button>
            </form>
        </div>
    );
};
export default WriteAnArticle;
Enter fullscreen mode Exit fullscreen mode

If done correctly, our application should look like the image below

Form to edit or submit an article/post in our app

Concluding our Application

To conclude our application, navigate to your App.tsx file in our src folder and add the code snippet below

import { BrowserRouter, Switch, Route } from "react-router-dom";
import Navbar from "./components/Navbar/Navbar";
import Post from "./pages/post";
import Homepage from "./pages/home";
import WriteAnArticle from "./pages/write";
import Profile from "./pages/profile";
const App = () => {
    return (
        <>
            <BrowserRouter>
                <Navbar />
                <Switch>
                    <Route path="/" exact component={Homepage} />
                    <Route path="/article/:id" exact component={Post} />
                    <Route path="/write-article" exact component={WriteAnArticle} />
                    <Route path="/edit-article/:id" exact component={WriteAnArticle} />
                </Switch>
            </BrowserRouter>
        </>
    );
};
export default App;
Enter fullscreen mode Exit fullscreen mode

The code above defines routes for all pages in our app,

  • / will navigate to the homepage
  • /article/:id will navigate to a Post page where we view a post completely. It’s a dynamic route and we will get the id from our url in the Post page.
  • /write-article will navigate to write article page
  • /edit-article/:id will take us to edit post page

We also added our Navbar component, if done correctly our application homepage should look like the image below.

React markdown

You can now go ahead and test the app with markdown syntax and extend it if you want.

Conclusion

In this tutorial, we looked at markdown, react-markdown, we learned how to install and render markdown safely with React markdown. We also reviewed how to use remark-gfm to extend react-markdown features and also how to make markdown styles display correcty in apps using tailwind.

You can learn more by looking at react markdown’s official documentation.

Discussion (0)