Feb 11, 2024

Building outstanding rich-text experiences with ProseMirror and React

Hey there 👋 I'm building CodeTrail, which helps engineering teams document and share knowledge close to the codebase with no friction. If you're still using Notion, Confluence or Google Docs to document your engineering work, give it a try and let me know what you think!

Rich-text editors power knowledge-sharing and content creation on the web. Back in the day, WYSIWYG editors for building blogs and submitting comments on websites were all the rage. Nowadays, products like Notion lead the way in simplifying the editing experience while enabling powerful use cases. If the editor works, we rarely think about what’s happening under the hood.

As it turns out, building rich-text editors is surprisingly complex. From modeling the underlying data structures used for storing formatted content to creating an intuitive UX that supports a natural editing experience, there’s a ton of heavy lifting that makes a well-designed editor feel simple.

Previously, implementations used various workarounds and hacks, but with the introduction of the contenteditable attribute, browsers enabled a new class of RichText editors using the DOM to render and manipulate content.

ProseMirror is a low-level set of building blocks to construct RichText editor experiences. Think of it as a thin abstraction layer on top of contenteditable HTML, taking care of schema enforcement, state management, and content rendering.

ProseMirror powers popular libraries like TipTap (which is used by other libraries like Novel and BlockNote). I believe a big part of its popularity comes from being intentionally unopinionated to create a solid foundation for customizable editing experiences.

In most cases, I recommend using a wrapper like TipTap simply because it’s easier to work with, but sometimes, you really need all the power. So let’s dive into the basic concepts behind ProseMirror.

Useful resources

Before we get started, I’ve collected some helpful resources that make working with ProseMirror infinitely easier.

First, the ProseMirror Developer Tools Chrome extension allows you to view the internal state of any ProseMirror instance detected on a webpage, including the document content (in a tree-like view, more on that later) with its history, plugins, and schema.

When you want to understand specific parts of ProseMirror, the official guide is an amazing starting point. The examples section also shows what you can build with ProseMirror, including Markdown support, collaborative editing, and text linting.

The ProseMirror Document Model

ProseMirror is organized around documents to represent content. A document contains block nodes. A block node may be a paragraph, image, list, or other element. Instead of nesting content in a tree structure, however, ProseMirror attempts to lift up inline text content as far as possible, leading to a very shallow structure.

Imagine we’re writing the following text:

<p>Hello <b>World</b></p>

You may expect the document to look like the following:

- node: paragraph
		- text: Hello
		- node: bold
				- text: World

This is not what ProseMirror does, however.

- node: paragraph
	- text: Hello
	- text: World (bold)

Marks like bold, emphasis, inline code, links, and more are not nested but attached to the text.

Flattening the data structure allows ProseMirror to use positioning within a paragraph for more natural text selection and editing operations without tree manipulation. Furthermore, each document has only one valid representation:

Adjacent text nodes with the same set of marks are always combined together, and empty text nodes are not allowed. The order in which marks appear is specified by the schema, too.

If you’re venturing into the true depths of ProseMirror, nodes have different property combinations:

A typical "paragraph" node will be a textblock, whereas a blockquote might be a block element whose content consists of other blocks. Text, hard breaks, and inline images are inline leaf nodes, and a horizontal rule node would be an example of a block leaf node.

Defining the Document Schema

Now that we’ve learned how documents are constructed, let’s continue with schemas. If you’re building a product like Notion, you might want to allow your documents or pages to include specific blocks like paragraphs, images, code, and more.

ProseMirror determines whether a document is valid by checking against a predefined schema. This is defined similarly to parser grammar, as you get to define which nodes exist in the system and what they may contain:

const trivialSchema = new Schema({
  nodes: {
    doc: {content: "paragraph+"},
    paragraph: {content: "text*"},
    text: {inline: true},
    /* ... and so on */
  }
})

In the example snippet above, a document may include one or more paragraphs, while a paragraph may include zero or more inline text nodes. Schemas can represent more complex data models like Markdown. If you want to add custom node types like interactive blocks, you need to register the nodes in your schema first.

In addition to handling validation, the schema also defines how ProseMirror renders your document in the DOM and how DOM content is parsed back into a node (relevant for enabling copy-and-paste support).

Creating an initial state

Whenever you create an editor instance, ProseMirror has to keep track of the current content, selection, plugins, and other data, all of which is wrapped up in the editor state. Creating the initial state for ProseMirror to use is as simple as

import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"

let state = EditorState.create({schema})

If you’re editing an existing document, you can pass the previous document right into the create call.

Rendering the editor

You could use ProseMirror completely headless and create your own view layer on top, but luckily, you don’t have to. Create an instance of EditorView by passing the DOM element to render the editor in as well as the initial state and ProseMirror will render the current document.

let view = new EditorView(document.body, {state})

This works great when you’re not running in some other framework like React. Let’s explore how we can wire up ProseMirror to work together with React.

Using React

When you’re building a React application, you’ll likely want to use React state management primitives like hooks. You might pass the current document content into the editor and expect updates to be passed back up. The NY Times has published a ProseMirror wrapper library for React that bridges the two worlds nicely.

To render the ProseMirror editor within React, simply render the <ProseMirror/> component:

import { ProseMirror } from "@nytimes/react-prosemirror";

const initialState = ...

export function ProseMirrorEditor() {
  const [mount, setMount] = useState<HTMLElement | null>(null);

  return (
    <ProseMirror mount={mount} defaultState={initialState}>
      <div ref={setMount} />
    </ProseMirror>
  );
}

If you want to lift up the state and manage it yourself, that works too:

export function ProseMirrorEditor() {
  const [mount, setMount] = useState<HTMLElement | null>(null);
  const [state, setState] = useState(EditorState.create({ schema }));

  return (
    <ProseMirror
      mount={mount}
      state={state}
      dispatchTransaction={(tr) => {
        setState((s) => s.apply(tr));
      }}
    >
      <div ref={setMount} />
    </ProseMirror>
  );
}

Internally, the <ProseMirror/> component will create, mount, and manage a ProseMirror EditorView instance through useEditorView.

Bonus: Node Views

I hear you: All this is great, but can we customize nodes like paragraphs using React? As always, there is more than one solution. You could change the styles using CSS for the entire editor. But if you need to use anything from the React component tree (e.g. user preferences), you’ll need something different. Unfortunately, the editor view breaks out of the React paradigm: Everything up until the ProseMirror component lives within React, but the EditorView directly manipulates the DOM.

Fortunately, node views allow you to take back control of the rendering process for a node. And with react-prosemirror, node views render React components using Portals.

Let’s say we want to apply TailwindCSS classes to every paragraph node. For this, we need to create a node view

import {
  useNodeViews,
  useEditorEventCallback,
  NodeViewComponentProps,
  react,
} from "@nytimes/react-prosemirror";
import { EditorState } from "prosemirror-state";
import { schema } from "prosemirror-schema-basic";

// Paragraph is more or less a normal React component, taking and rendering
// its children. The actual children will be constructed by ProseMirror and
// passed in here. Take a look at the NodeViewComponentProps type to
// see what other props will be passed to NodeView components.
function Paragraph({ children }: NodeViewComponentProps) {
  const onClick = useEditorEventCallback((view) => view.dispatch(whatever));
  return <p onClick={onClick}>{children}</p>;
}

// Make sure that your ReactNodeViews are defined outside of
// your component, or are properly memoized. ProseMirror will
// teardown and rebuild all NodeViews if the nodeView prop is
// updated, leading to unbounded recursion if this object doesn't
// have a stable reference.
const reactNodeViews = {
  paragraph: () => ({
    component: Paragraph,
    // We render the Paragraph component itself into a div element
    dom: document.createElement("div"),
    // We render the paragraph node's ProseMirror contents into
    // a span, which will be passed as children to the Paragraph
    // component.
    contentDOM: document.createElement("span"),
  }),
};

This isn’t the classic node view that ProseMirror exposes, instead, we’re rendering a React component. Next, we need to register our custom node views in the editor

const state = EditorState.create({
  schema,
  // You must add the react plugin if you use
  // the useNodeViews or useNodePos hook.
  plugins: [react()],
});

function ProseMirrorEditor() {
  const { nodeViews, renderNodeViews } = useNodeViews(reactNodeViews);
  const [mount, setMount] = useState<HTMLElement | null>(null);

  return (
    <ProseMirror mount={mount} nodeViews={nodeViews} defaultState={state}>
      <div ref={setMount} />
      {renderNodeViews()}
    </ProseMirror>
  );
}

This way, we can use ProseMirror for a natural rich-text editing experience and add our custom components back into the document. It’s the best of both worlds!


ProseMirror is incredibly powerful and can be used for building best-in-class editors on the web. At CodeTrail, we’re building our core documentation experience using ProseMirror, and I’ll dive deeper into some practical applications in upcoming posts!

Thanks for reading this post 🙌 I'm building CodeTrail, which helps engineering teams document and share knowledge close to the codebase with no friction. If you're still using Notion, Confluence or Google Docs to document your engineering work, give it a try and let me know what you think!

Bruno Scheufler

At the intersection of software engineering
and management.

On other platforms