DEV Community

Andrei Kondratev
Andrei Kondratev

Posted on • Edited on

React Batch Mount

Hey there!
In the Wild Internet there are many different articles about increase performance of React applications. But mainly these articles describe how to reduce the number of component rerenders. What if the application takes a long time to mount components?

Let's describe the problem. Your user wants to see a list of some items (for example, musics, messages, transactions, etc). This list may contain thousands of items, and each item is mounted complex component with calculations effects and even explicit manipulation of the children. Obviously, mounting all components for all items at once is a very bad idea and no on does that.

What is usually done in this case? In this case, a tichnique is used that allows you not render components for all data. These are techniques such as pagination, lazy loading, virtualization and so on. But what if the list of items in the user interface is represented by a form with thousands of inputs? In some cases, you can any of the previous techniques, but in other cases you must render all components for correct form work and a good UX.

One solution to this problem is not to mount all the components at once, but in small parts. In this case, the user will have to wait until all the components are mounted, but the browser will have time to print frames and the user will see the dynamic loading and even interact with loaded part.

React.Suspense and React.lazy

How to implement this idea? The straightforward way is use the component that has some state and provides the knowledge which children components are mounted. This can lead to problems with extra renders, complex memoization and so on.

From React 16.6, you can use React.Suspense and React.lazy for rendering components in the dynamic loaded modules. lazy returns a special component that is specially mounted and processed in the React tree. The dynamic import returns a promise that is wrapped in the Lazy component. When the promise is fulfilled, the Lazy component is pointwise updated without triggering an update to its ancestors. We can replace the dynamic import promise to a promise that we control and mount content of Lazy component when we want.

// The simple react component
const Some = () => <div>It's lazy wrapped component</div>;

// Create a promise that is resolved by a object that
// looks like a dynamic import object
const promise = Promise.resolve({default: Some});

// Wrap the promise the lazy function
const LazyWrapped = lazy(() => promise);
Enter fullscreen mode Exit fullscreen mode

Now we can try to mount LazyWrapped component to React tree and get an error

A React component suspended while rendering, but no fallback UI was specified.
Enter fullscreen mode Exit fullscreen mode

The Lazy component requires React.Suspense to be among its ancestors. These components is completely controlled by React.

const App = () => (
  <Suspense fallback="Loading ...">
    {Array.from({ length: 100 }).map((_, i) => (
      <LazyWrapped key={i} />
    ))}
  </Suspense>
);
Enter fullscreen mode Exit fullscreen mode

Demo

What about the rerenders of these components? Let's add console.log to several components.

// HOC create component that close n
const Some = (n) => () => {
  console.log("render Some", n);
  return <div>It's lazy wrapped component</div>;
};

const LazyWrapped1 = lazy(
  () =>
    new Promise((resolve) => {
      setTimeout(() => {
        console.log("promise 1 resolved");
        resolve({ default: Some(1) });
      }, 300);
    })
);

const LazyWrapped2 = lazy(
  () =>
    new Promise((resolve) => {
      setTimeout(() => {
        console.log("promise 2 resolved");
        resolve({ default: Some(2) });
      }, 500);
    })
);

const App = () => {
  console.log("render App");
  return (
    <Suspense fallback="Loading ...">
      <LazyWrapped1 />
      <LazyWrapped2 />
    </Suspense>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now we have only two Lazy component, but theirs promises are fulfilled at different times. When the root component and the lazy components are rerender, they print to the console about it. And the promises print too when its are resolved. Let's take a look at the console.

render App 
promise 1 resolved 
render Some 1
promise 2 resolved 
render Some 2
Enter fullscreen mode Exit fullscreen mode

Demo

How you can see when the promises are fulfilled only the Lazy component rerenders. Therefore we can create some mechanism that controls promises fulfilled. It'll allow to control mount of the components without rerendering other parts of the application.

react-batch-mount

I try to implement its mechanism in react-batch-mount library.
The main part is hidded inside the library and name scheduler. The scheduler has queue of the promises resolve functions. If the queue isn't empty the scheduler plans the next batch mount via requestAnimationFrame.

To connect a component to batch rendering, you can use the HOC batchMount.

const Some = batchMount(() => {
  return <div>It's batched component</div>;
});
Enter fullscreen mode Exit fullscreen mode

batchMount internally creates a promise which will be resolved by the scheduler. This promise is wrapped in React.lazy and the Lazy component is returns by batchMount. We can use Some component inside Suspense in our App.

const App = () => {
  return (
    <div>
      <Suspense fallback={<div>Loading ... </div>}>
        {Array.from({ length: 50 }).map((_, i) => (
          <Some key={i} />
        ))}
      </Suspense>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Demo in TypeScript

You can pass options to batchMount at the second parameter. One of the options if fallback. If fallback is passed batchMount wrap the Lazy component to Suspense with the passed fallback. This will create Suspense to each item.

const Some = batchMount(
  () => {
    return <div>It's batched component</div>;
  },
  {
    fallback: <div>Loading</div>
  }
);
Enter fullscreen mode Exit fullscreen mode

Demo

By default the scheduler mounts component at a time. You can configure its behavior using the setGlobalConfig function. This function takes either {batchSize: number} or {budget: number}. If you explicitly specify the batch size, the scheduler will fill the batch of the specified size if there are enough components. budget is the time in milliseconds that the scheduler should try to spend on mounting the batch. When the previous batch is fully mounted, the scheduler will calculate the size of the next batch based on the mount time of the previous and specified budget.

To see the full power of the budget, let's try to simulate a long component mounting.

const useHighload = (ms) => {
  const mounted = useRef(false);
  if (!mounted.current) {
    mounted.current = true;

    const start = Date.now();
    let now = Date.now();
    while (now - start < ms) {
      now = Date.now();
    }
  }
};

const Some = batchMount(
  () => {
    useHighload(100);
    return <div>It's batched component</div>;
  },
  {
    fallback: <div>Loading</div>
  }
);

setGlobalConfig({budget: 500});
Enter fullscreen mode Exit fullscreen mode

Demo

What's next

Now you know a new approach to optimizing a React application and several internal features of React. You can look at the rest of react-batch-mount features, experiment with it, maybe even use it in your application.

Top comments (0)