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!
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.
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.
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!
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.
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
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.
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
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.