Announcing React ProseMirror

My colleagues and I at The New York Times have spent a few years now using ProseMirror in a React application and using React to create node views. Over the past six months we started to take a new approach and we’ve had enough success with it that we wanted to share it with the community and invite feedback and collaboration.

It’s very early days for this library. We have a version of this code in use in production, but not exactly this library, yet, as we’ve only just extracted it from our application. With that caveat out of the way, if you want to dive in and take a look, the project is on GitHub.

The library aims to provide solutions to two major problems:

  • How should a React application safely access a ProseMirror view in a way that prevents state tearing?
  • How should a React application render node views with React in a way that feels as close as possible to idiomatic use of ProseMirror?

We hope you find it useful or interesting and we welcome your thoughts!

11 Likes

This is really interesting. In case you had a look into it during development, could you elaborate a bit on how your library differs from how Tiptap approaches the problem?

I’m not familiar with Tiptap, but from a quick read it looks like it doesn’t address building node views as React components. The library we’ve made is focused heavily on hooks to safely access the editor view from within node view components.

Ah, somehow I missed that in the docs. Probably because I was looking on my phone and not seeing everything in the documentation. I’ll take a look.

It looks like Tiptap takes the same approach we did wherein React node views register themselves to be rendered as portals.

Compared with Tiptap, I think our approach is lighter touch, sticking closer to the ProseMirror API and trying to not have too much API surface of its own. Whether that’s good or bad is probably a matter of preference.

Our main contribution is probably the useEditorEvent and useEditorEffect hooks, which ensure that React node views can access the whole editor view safely from the React lifecycle in order to call methods like coordsAtPos. We found this to be particularly important when the editor state itself is stored in another state management system like Redux and you want to make Redux-connected node view components that see a consistent view of the editor state.

1 Like

It’s very possible that TipTap is solving this problem some way with its editor abstraction, but I’d need more time or some guidance to digest how.

Last time I dug into the TipTap integration with React, I noticed something a bit hacky which was the use of a forceUpdate to get React to re-render.

There was a PR that tried to get fix this using the new useSyncExternalStore hook: feat: add EditorProvider and hooks to provide granular updates by umar-ahmed · Pull Request #2626 · ueberdosis/tiptap · GitHub but it didn’t get merged.

I’ve noticed react-prosemirror uses forceUpdate and also a unstable_batchedUpdates function that I’m not familiar with.

1 Like

I’ve noticed react-prosemirror uses forceUpdate and also a unstable_batchedUpdates function that I’m not familiar with.

It does! unstable_batchedUpdates is actually the default behavior now in React 18, but for earlier React versions, it was necessary to “opt in” to batching state updates that originate from outside of React-managed code. This article explains it in a bit more depth. React Redux actually re-exports this method as batch, and their docs have some explanation as well.

The forceUpdate is necessary in our case for the LayoutGroup implementation, which relies on the useLayoutEffect hook to update a Set stored in a Ref. Since Ref updates don’t result in re-renders, we have to separately force a re-render by bumping some state!

2 Likes

Howdy! I’m another one of the developers of React ProseMirror. The way I explained the distinction from TipTap elsewhere is pretty similar to what @tilgovi said here:

At the Times, we’ve been using React and ProseMirror for something close to 5 years now, and have a pretty considerable amount of pure ProseMirror code in our codebase. TipTap is very cool, but it introduces a lot of new concepts layered on top of ProseMirror, so it would have been a very significant project to try to migrate to it.

On the flip side, @nytimes/react-prosemirror tries to avoid introducing new concepts and idioms as a rule. It lets you write React code that looks like React code and ProseMirror code that looks like ProseMirror code, and it handles the seam. We wanted to avoid adding a “third thing” for folks to have to learn; our team already has considerable expertise with ProseMirror, so we wanted to be able to continue to leverage that knowledge.

I expect that TipTap and React ProseMirror are going to appeal to different audiences. TipTap is a fantastic “batteries included” rich text editing library, which is great for teams that are starting something new and are new to rich text editing. React ProseMirror is a small adapter layer for ProseMirror (the way React Redux is a small adapter layer for Redux!), meant for teams that are already comfortable with ProseMirror, and maybe even have existing projects that try to use React and ProseMirror together.

3 Likes

While building react-prosemirror, did you see any opportunity to remove extra elements needed with making non-vanilla NodeViews?

For example, with vanilla NodeViews, you could define dom: document.createElement("p") and have the contentDOM equal to that, resulting in this structure

<p>hello world</p>

as opposed to

<div>
  <p>
    <div><span>hello world</span></div>
  </p>
</div>

Other libraries like TipTap which try to build support for React node-views also run into a similar problem because portals need an element to mount to, and React rendering is asynchronous (?). (Old issue Async contentDOMRef for NodeViews using react · Issue #803 · ProseMirror/prosemirror · GitHub)

Also curious since you’ve built React node-views, whether you’ve thought about React SSR + ProseMirror content, sort of like GitHub - splitbee/react-notion: A fast React renderer for Notion pages. I feel like there might be a lot of duplicated code between node-views and displaying the content again in a non-editing context.

The way NodeViews work is adding a boundary between DOM elements where ProseMirror doesn’t capture mutations. In that paragraph example I guess you could technically get rid of the outer <div> but not the inner since that’s where the contentDOM goes in. Otherwise ProseMirror can’t figure out whether a mutation in <p> should be omitted or not.

And cool work btw! Personally I’ve ditched React but interesting optimizations nonetheless.

We did, actually! It’s… not a trivial challenge to overcome, but I opened an RFC that sketches out a possible approach here. The gist of it is: We recompiled react-dom to declare itself as a “secondary” renderer, rather than a primary renderer, which allows us to call flushSync on some React sub-trees from within the primary React tree’s render cycle. I actually gave a talk on the basics of this yesterday, feel free to take a look at the slides (press “S” when you load the page to see the speaker’s notes).

The major downsides to this approach are that contexts have to be bound across the renderer gap manually (note the new “contexts” prop on the ProseMirror component in that branch), and that each NodeView goes in its own React root, rather than showing up in the appropriate place in the primary React tree. It does let us truly get the expected HTML, though; in your example, that branch would actually render

<p>hello world</p>

with no wrapping or nested elements!

1 Like

I have a package that is designed to solve the node view problem:

2 Likes

I can elaborate a little bit on the use of forceUpdate in both codebases, @bhl.

It looks like TipTap uses it to force a re-rendering in React in response to a transaction on the editor. The possibility for useSyncExternalStore may have been if TipTop treats the Editor View as a source of external state.

In react-prosemirror, we don’t attempt to treat the Editor View as a source of external state, because the expectation is that React is actually the source of the state for the Editor View. Any dispatching of a transaction should result in changing React state, and the state flows back to the Editor VIew through React, without any need for a forced update. React Node Views that get updated by ProseMirror will also call setState on their components in response to the Node View update method getting called by ProseMirror. Since this happens during the creation of a layout effect of the editor component, React flushes these updates synchronously.

Where react-prosemirror uses forceUpdate is a bit different from TipTap. Since state comes from React and only flows to the Editor View during a layout effect, we wanted to discourage use of the Editor View from outside effects and events, in order to prevent code from observing React state that is out of sync with Editor View state. We provide the useEditorEffect hook to let components get access to the editor view. Due to the fact that event handlers may cause components to update without updating their ancestors, and the fact that an ancestor generally controls the state of the Editor View, we need to provide components with a way to hoist layout effects up to the level of the component that controls the Editor View and force those effects to flush. We implemented this functionality as the LayoutGroup component and useLayoutGroupEffect, which is not at all ProseMirror specific and could be extracted into its own library.

The LayoutGroup component and useLayoutGroupEffect hook fill a niche that I didn’t see filled. React has layout effects that run synchronously after an update, bottom-to-top. The order makes sense because the common need in layout is to lay out a parent based on the layout of its children (think of a <div> being sized to fit its children). Our layout group lets components register effects that run after all their regular layout effects and those of other components in the group. That happens to be really useful for our ProseMirror integration, because it lets components that render inside the editor register effects to run after the ProseMirror updates its view.

1 Like

Exciting stuff @SMores @tilgovi. From a quick view, the nodeviews implementation looks really solid. Great to see NYT open sourcing this!

I’m wondering if you’ve also considered Remirror, and if you see significant architectural differences between react-prosemirror and remirror that caused you to create a new library. For example, see this info on Remirror nodeviews: Creating NodeViews with content in Remirror | Remirror

1 Like

I’m also interested to hear if Remirror was considered and how it differs.

We did look at Remirror. There are similarities, but Remirror is a much heavier framework. We had an existing, non-trivial application that was using ProseMirror directly, with many ProseMirror plugins and many existing components. We wanted something light and close to the ProseMirror API to migrate to that focused only on state and rendering integration.

2 Likes

As @tilgovi says! I elaborated a bit on this in response to the earlier question about TipTap, as well: Announcing React ProseMirror - #10 by SMores

If my understanding is correct, only nytimes/react-prosemirror provides a solution that allows for the storage of the editor state outside of the editor component. In contrast, Remirror and Tiptap seem to offer functionality only for setting the initial state. They do not fully implement the MVVM pattern. If I want to update the content of the editor, I am compelled to use useEffect.

// nytimes/react-prosemirror
<ProseMirror
  mount={mount}
  state={editorState}
  dispatchTransaction={(tr) => {
    setEditorState(s => s.apply(tr))
  }}
>

// Remirror
useEffect(() => {
  if (!isEditMode)
    getContext()?.setContent(myContent);
});

<Remirror
  manager={manager}
  initialContent={state}
  onChange={({state}) => setState(state)}
>
  <OnChangeJSON onChange={content => dispatch(myContent))} />
  <EditorComponent />
</Remirror>