Marrow and Figma are online collaborative canvas tools that became very popular during the pandemic. Instead of using sticky notes on a physical wall, you can add a virtual post-it and an array of other things to a virtual canvas. This lets teams collaborate virtually in ways that feel familiar to the physical world.
Earlier I wrote an article showing how to create Figma/Mero clones in React and Typescript. Code in the article It was designed to be so easy to understand, and in this article, we’re going to make it even better. The code is used dndkit To drag and drop, and D3 Zoom For panning and zooming. There were four components (Appfor , for , for , . Canvasfor , for , for , . Draggable And Addable), and about 250 lines of code. You don’t need to read the original article to understand this.

Standard improvements such as useCallbackfor , for , for , . memoand so on made it twice as fast when dragging, but panning and zooming made no difference. More creative/deep fixes made it ten times faster in most cases.
You can see better code on GitHub And there is one Live demo on GitHub pages To test the speed with 100,000 cards.
Table of Contents
How to Measure Performance in React Apps
There are three common ways to measure performance in React apps
These tools are all great, but none of them are a perfect fit in this case. In most codebases, the main issue is the execution time (both our code and the React framework) of JavaScript code. However, after all your code has run and React has updated the DOM, the browser still has a lot of work to do:

In this case, it was the browser setting and rendering time that mattered, and it didn’t account for reactive profiling.
You can use custom tracks in the Chrome Dev Tools profiler, but it’s very cumbersome to use.
For us, JavaScript Performance API is the best option, which provides results that are close to what the user experiences, and is relatively easy to use.
First, we call performance.mark In the event handler that starts the action, with a string to describe the time point. For example, when starting a zoom or pan operation:
zoomBehavior.on("start", () => {
performance.mark('zoomingOrPanningStart');
}
Then, I a useEffect Hook, we call performance.mark Again, and call performance.measure Calculating the time between two points:
useEffect(() => {
performance.mark('zoomingOrPanningEnd');
performance.measure('zoomingOrPanning', 'zoomingOrPanningStart', 'zoomingOrPanningEnd');
});
Reaction to documents It states that useEffect Usually the browser fires after painting the latest screen, which is what we want.
It’s not perfect, and will vary depending on the machine’s specifications, and what else the machine is doing at the time, but it was good enough to confirm which optimizations worked best. It is possible to go further if you need to. For example, using Cypress for automatic and profile scenariospossibly running several times to get a good meaning, or use Browse stacks for testing on a variety of devices.
How to conduct a performance investigation
Most involve using an investigation Dev Tools Profiler React To record user interaction profiles.
Performance data shows how many commits there were in the profile, and how long each one took, which is a great way to see if there are too many commits.
Each commit displays a flame chart showing which components committed and why they committed again. Finding ways to avoid reproducing it, and finding that the memorization strategy is working as expected. There are some caveats though. It says often ‘Parent component provided’which is misleading the default text when it doesn’t understand what happened (and is often caused by a change in the parent’s context). It also says things like ‘Hook 9 changed’, which takes time to work out how the hook changed.
The flame chart also shows how long each component takes to render. This helps us target the components of the problem that we need to focus on.
How to pan and zoom the canvas
Original The canvas The element used a CSS transform translate3d(x, y, k) To pan and zoom the canvas. This works, but it doesn’t scale the child elements, so when the zoom changes, all the cards on the canvas have to be re-rendered with the new zoom level (with a new CSS transform).scale(${canvasTransform.k}))
"canvas"
style={{
transform: `translate3d(${transform.x}px, ${transform.y}px, ${transform.k}px)`,
...
}}>
...
<div
className="card"
style={{
...
transform: `scale(${canvasTransform.k})`,
}}>
...
div>
I changed it to using translateX(x) translateY(y) scale(k)which has the same effect, but measures child elements. That way, when the zoom changes, none of the cards will be rendered again ( style Of card The component is no longer used canvasTransform.k)
"canvas"
style={{
transform: `translateX(${transform.x}px) translateY(${transform.y}px) scale(${transform.k})`,
...
}}>
...
<div
className="card"
...
div>
Canvas Still need to re-render every time the pan or zoom changes, and it’s possible to avoid that useRefand modifying CSS with direct JavaScript DOM manipulation D3-Zoom Event handler. It doesn’t improve performance significantly, and is a definite hack, so the tradeoff isn’t worth it.
Zooming and panning both become slightly slower when the canvas is moved too far and there are (much) more cards on the screen, forcing the browser to render them all. It’s still viable at 100,000 cards though. There is something you can do about it. A simple option is to limit the maximum zoom range. This is a practical change, so probably something that doesn’t meet the requirements, but it’s easy to use in D3 Zoom. scaleExtent:
zoom().scaleExtent((0.1, 100))
Another option is to create a bitmap for a very low zoom level and render it as a single element. This may be difficult, but it means that the functionality will not change.
How to improve dragging a card around the canvas
Starting drag
useDraggable From the hook DndContext Causes some renderers to crash when starting a drag operation.
It is possible to improve it by changing it Draggable The component just holds that hook (and the objects that use it) and one DraggableInner Component of everything else (within memo) it works well to reduce repeaters, in DraggableInner Almost never renders, and drag operations improve startup speed. However, it was still quite slow, and time was under everyone’s control DndContext.
A better option is to create a new one NonDraggable Component, which looks just fine Draggable component, but does not mix with it DndContext. These cards are displayed on the canvas, and there is a onMouseEnter event, to change to Draggable Component for the active card, so that dragging continues.
const onMouseEnter = useCallback(() => {
setHoverCard(card);
}, ());
This works well, and significantly improves speed when starting drag operations, but with large numbers of cards it was still quite slow. Almost nothing was reoccurring, but still costing time when using memoas needed to check if components have changed.
To fix this, we create a AllCards component, which contains all the cards in the canvas NonDraggable Component Since it always presents all cards, it never needs to be re-presented, and is used with memo. So use a instead of each individual card memo (with the associated time cost), there is now only one component memo. The drag still works to make it dynamic Draggable Ingredients are served from above NonDraggable component below it. There is also one Cover component below it, so that when Draggable The component is dragged, NonDraggable The component remains hidden below the component.
The original code, where each card is one Draggable Ingredients:
{cards.map((card) => (
<Draggable card={card} key={card.id} canvasTransform={transform} />
))}
Modified code, where AllCards The component renders all cards as NonDraggable components, and then a Cover And a Draggable Component for active card.
<DndContext ...>
<Cover card={hoverCard} />
<Draggable card={hoverCard} canvasTransform={transform} />
DndContext>
It works very well. With very few cards, the speed is the same, but with a large number of cards, it is about twenty times faster.
Now there is a new potential performance issue with this onMouseEnter The event that turns into Draggable component for the active card, but this only adds two components to the DOM, and is very fast even with large numbers of cards.
Eliminate drag
Ending the drag operation is difficult to optimize, because the position of the card changes, and it requires re-rendering, which means that AllCards The component also has to be rendered again.
You can see the original code below. Even when using memo With the draggable component, the end-drag operation still takes 2500ms with 100,000 cards, mostly due to its complexity. Draggable The component and its integration with Dndkit.
{cards.map((card) => (
<Draggable card={card} key={card.id} canvasTransform={transform} />
))}
However, now we use NonDraggable Ingredients, all of which memo Successfully, and only the dragged card is reproduced. There is still a one-time cost while using memoand this is the slowest part of the solution, but it adds up to 500ms with 100,000 cards.
const NonDraggable = memo(...)
const AllCards = memo((cards, setHoverCard) => {
<>
{cards.map((card) => {
<NonDraggable card={card} key={card.id} setHoverCard={setHoverCard} />);
})}
>;
});
Results
The base non-optimized version started to slow down between 1000 and 5000 cards. Standard optimization improved it to about 10,000 cards, and high optimization brought it to about 100,000 cards. The trade-off is that the code becomes significantly more complex, making it harder to understand and modify, especially for people new to the codebase.
| Pan (MS) | Zoom (ms) | Start Drag (ms) | End Drag (ms) | Card Hoover (MS) | ||
| 1000 cards | The basis | 3 | 4 | 200 | 50 | – from |
| Basic optimization | 2 | 3 | 200 | 30 | – from | |
| Extreme optimization | 10 | 10 | 7 | 15 | 2 | |
| 5000 cards | The basis | 20 | 150 | 450 | 200 | – from |
| Basic optimization | 20 | 150 | 200 | 80 | – from | |
| Extreme optimization | 10 | 10 | 25 | 40 | 2 | |
| 10,000 cards | The basis | 50 | 300 | 900 | 400 | – from |
| Basic optimization | 50 | 300 | 400 | 180 | – from | |
| Extreme optimization | 25 | 25 | 50 | 50 | 2 | |
| 50,000 cards | The basis | 1000 | 1500 | 4000 | 1800 | – from |
| Basic optimization | 1000 | 1500 | 1900 | 900 | – from | |
| Extreme optimization | 150 | 150 | 150 | 250 | 5 | |
| 100,000 cards | The basis | – from | – from | – from | – from | – from |
| Basic optimization | 3000 | 4500 | 5000 | 2500 | – from | |
| Extreme optimization | 150 | 250 | 300 | 500 | 15 |
Summary
It’s unusual for a standard React app to display 100,000 or more objects on screen, but in a highly graphical codebase, it becomes much more likely.
With these numbers, the browser rendering engine is likely to take a considerable amount of time, so it’s better to use the Performance API to measure performance rather than the usual React tools.
Standard reactive optimization strategies work and improve the situation, but by finding ways to avoid renders, and even to avoid too many. , need to go further. memo Comparison