Journey towards a performant web editor

TLDR

This post presents different performance improvement and monitoring techniques that can be used in any React/Redux application.

Akin to the React Concurrent mode, it also introduces an async mode for Redux applications where the UI can’t be windowized.

WordPress 5.0 included a new block-based content editor. The editor is built as typical react/redux web application with a global store and a tree of UI components retrieving data using state selectors and performing mutations using actions.

Note To be more precise, The WordPress block Editor (called Gutenberg sometimes) uses multiple stores, but for the purpose of this post, we can simplify and assume it uses a single one.

Relying on the shoulders of giants: react-redux

The main performance bottleneck for most React/Redux applications is the fact that any change in the global state can potentially trigger updates in all the components subscribe to the store updates.

Fortunately, the simple fact of using react-redux is enough to solve most of these performance issues. The library is highly-optimized out of the box.

Example

const mapStateToProps = state => ( {
  block: getBlock( state )
} );
const MyBlockComponent = connect( mapStateToProps )( BlockComponent )

In the example above, each time the global state is changed, the mapStateToProps function is executed to compute the updated props passed to the underlying UI component.

By default if the computed props (block in our example) don’t change, the underlying component (Block in the example) is not re-rendered.

It’s important to note that react-redux‘s connect function performs a shallow comparison to check if the computed props changed or not. This means generating new object instances in mapStateToProps should be avoided and selectors (getBlock in our instance) should ensure that it returns the same block object instance on each call unless an actual change to the block object has been made.

// Bad: a new block object is generated on each render, causing rerenders even if the block name didn't change.
const mapStateToProps = state => ( {
  block: { name: getBlockName( state ) } 
} );
const MyBlockComponent = connect( mapStateToProps )( BlockComponent )

// Bad: Doing the same thing in a factorized selector is bad as well. It is strictly equivalent.
const getBlock = ( state ) => ( { name: getBlockName( state ) } );

Track component re-rendering

The first thing you should track when you notice performance degradations is whether you have components being re-rendered too often and without any meaningful prop change.

To do so, install the React Developer Tools browser extension, check the Highlight Updates option and notice the flashing borders around all the components being re-rendered. You can also inspect a given component and check which props are changing when it’s re-rendered.

Proxifying event handlers

Often, when react-redux‘s connect function, you end up providing event handlers that depend on props. For components optimized for purity (don’t render when props change), this can lead to unwanted rerenders because the event handler end-up being recreated on each render.

// typical connect with event handlers
const mapDispatchToProps = ( dispatch, ownProps ) => ( {
  removeBlock: () => dispatch( { type: 'REMOVE_BLOCK', clientId: ownProps.clientId } )
} );
const MyBlockComponent = connect( undefined, mapDispatchToProps )( BlockComponent )

To address this issue @wordpress/data implemented its withDispatch higher-order component (equivalent to connect) with the idea that we only care about these event handlers when the event happens (click on a button…), so instead of recreating the event handler on each render, withDispatch provides proxies to the actual event handlers, these proxies instances don’t change per render and evaluate the actual event handlers when they get called. The assumption here is that the event handlers list won’t change depending on the component’s props.

Note that the data module offers now useSelect and useDispatch React hooks requiring a different technique to optimize event handlers that needs data dependencies.

Optimize the selectors

Now, that we ensured that our components re-render only if necessary (one of the props changed), we started monitoring our application to find the bottleneck.

When building an editor, one of the most important interactions you’d want to optimize for is “typing”. When quickly typing in the editor, the user shouldn’t notice slowness, the feedback (character being printed) should be immediate. Using the Chrome Performance Tools, we started monitoring the keypress event duration.

Keypress event monitoring

Quickly, we realized that the more content the editor is showing, the more rendered components we have, the worse the typing performance gets. And even if the components were memoized, their selectors were still being called on each change even if their result didn’t change. Selector calls quickly became the bottleneck of the editor’s performance. Our next step was to optimize the performance of our selectors.

The most important technique to be aware of here is what we call function memoization. Memoizing a function means that a function is not executed twice unless its inputs (arguments) change.

In the React/Redux world, there are a number of libraries allowing you to memoize selectors, some of the most used one being reselect and rememo.

Note Memoization is a good technique but it’s important to monitor and measure the performance improvements. Start by memoizing the less-performant selectors. Memoization is also a technique that can be used to avoid creating new objects/array instances if the inputs are the same (which then prevents components from re-rendering if not necessary).

Reshape the state tree to avoid high selector cache invalidation rates

In a typical Redux store, you’ll have some data that changes very often and other state values that don’t. It is important that these two things stay separate in the Redux state tree for better selector performance.

Let’s take the following blocks redux state as an example:

const state = { blocks: [
    {
        id: "block-id-1",
        name: "core/paragraph",
        attributes: {
           content: "myValue"
        } 
    },
    {
        id: "block-id-2",
        name: "core/paragraph",
        attributes: {
           content: "another value"
        } 
    }
] };

Now imagine we have a selectors that returns an ordered set of block ids

const getBlockIds = (state) => state.blocks.map(block => block.id);

If we want to optimize the selector to avoid computing a new array if the state stays the same, we’d write something like:

const getBlockIds = createSelector(  
  state => state.blocks.map(block => block.id),
  state => [ state.blocks ]
);

The second argument here tells the selector to avoid recomputing the array if the state.blocks value didn’t change.

That’s a good first step, the problem though is that we don’t reorder or add new blocks as often as we change the block attributes, the selector value won’t change, but the whole “blocks” state will causing the selector to recompute again.

This issue is solved by identifying what are the parts of the state that change often, and the ones that change less. Ideally, we should group all state values that change “together” under the same state key.

Here’s an example of a rewrite that can lead to better performance:

const state = {
   blockOrder: [ 'block-id-1', 'block-id-2 ],
   blockNames: {
       'block-id-1': 'core/paragraph',
       'block-id-2': 'core/paragraph',
   },
   blockAttributes: {
       'block-id-1': {
           content: "myValue"
       },
       'block-id-2': {
           content: "another value"
       }
   }
};

const getBlockIds = state => state.blockOrder;

You’ll notice that now the array returned by getBlockIds won’t change unless the order or the list of blocks is actually changed. An update to the attributes of blocks won’t refresh the value returned by that selector.

Async mode

Memoizing slow selectors did have an impact on the performance but overall, the high-number of function calls (selector calls) was still an issue even if a single function call is very fast. It became apparent that instead of optimizing the selectors themselves, our best bet would be to avoid calling the selectors entirely.

This is a typical performance issue in React-Redux applications and the approach that most people take to solve is using windowing techniques. Windowing means that if a component is not currently visible on the screen, why do we need to render it to the DOM and keep it up to date. react-window is one of the most used libraries when it comes to implementing windowing in React applications.

Unfortunately, windowing is not an option we can consider for the block-editor for multiple reasons:

  • In general, windowing works by computing the height of the hidden elements and adapting the scrolling behavior to the computed height even if the elements are not rendered on the DOM. In the case of the block editor, it’s actually impossible to know or compute the height of the blocks and their position without really rendering them to the DOM.
  • Another downside is the A11y support, screen readers tend to scan the whole DOM to provide alternative ways to navigate the page without relying on a notion of “visible elements” or not. Something that is not rendered on the DOM, is something you can’t navigate to.

For these reasons, we had to be a bit more innovative here. While the initial rendering of the components had a cost, the most important thing for us is to keep the UI responsive as we type, and the bottleneck at this point was the number of selectors being called.

That said, in a typical block editor, when you’re editing a given block, it is very rare that an update to that block affects other parts of the content. Starting from this hypothesis, we implemented the Async Mode.

What is the Data Module’s async mode?

The Async mode is the idea that you can decide whether to refresh/rerender a part of the React component tree synchronously or asynchronously.

Rendering asynchronously in this context means that if a change is triggered in the global state (Redux store), the subscribers (selectors) are not called synchronously, instead, we wait for the browser to be idle and perform the updates to React Tree.

It is very similar to the Concurrent mode proposed by the React Team in the last React versions. The difference is that React’s concurrent mode use setState calls to defer the rendering but in our case, we want to defer the selector calls which in the call chain happen before the React setState calls.

How did we apply the async mode to the editor?

Our approach was to consider the currently selected block as a synchronous React component tree, while considering all the remaining blocks as asynchronous.

It is possible to opt-out of Async mode for a given block. This can be useful for blocks that rely on other blocks to render properly. (E.g. Table of content block)

At this point, our biggest performance issues were solved, but still, we wanted to continue improving the editor’s performance as much as we can.

What’s next

Building a performant editor is a challenge, an on-going one, and unlike regular features, performance is not something you can implement and forget about, it’ts a continuous workflow. If you have to take something out of my lenghty blog post, I hope it’s this:

  • Identify the flows you want to optimize,
  • Identify the bottlenecks of your application,
  • Measure constantly, ideally in an automatic way on each PR,
  • Don’t learn and apply optimization techniques blindly, instead, read about them, know their existence and try to adopt the best techniques based on your particular use-cases.

2 responses to “Journey towards a performant web editor”

  1. Super Insightful!

    For the article “skimmers”, you may want add the ‘good’ examples below the bad ones in the “Relying on the shoulders of giants: react-redux” section to drive home the point. 🙂

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from Riad Benguella

Subscribe now to keep reading and get access to the full archive.

Continue reading