Follow these step-by-step process to enhance your React Flow project's performance.
React Flow is a library vulnerable to a diagram’s performance pitfalls. Especially when a developer isn't cautious while coding.
Even one non-optimized line of code can cause unnecessary re-rendering of the diagram's elements on every state change (mainly when the positions of nodes are updated). As a result, an application works slowly.
To understand how easily performance issues can arise, let’s analyze the following example. When you drag a node on the diagram, the application acts as follows:
Re-rendering of a single component usually doesn't impact an application's performance.
However, if we unintentionally make the states of nodes dependent on each other or place heavy components as children within the main ReactFlow component, the application may lose its smoothness and fail to meet performance requirements.
In addition, there is a general rule in the React ecosystem not to optimize code too early unless there are performance issues.
As for React Flow, if such issues occur later in a development process, they can be difficult to overcome without significant changes in code logic.
So, the sooner you work on software performance optimization, the better.
Now, I will share five hints on avoiding performance pitfalls in React Flow applications. I based all of them on a tested project’s performance audit and my over 11 years of experience building diagrams in multiple visual applications.
The data I present in this article comes from a project consisting of 100 nodes. Every node had two Handles and rendered one of two things:
The base FPS (frames per second) with optimal performance in the project on my computer is 60 FPS.
The basic usage of the <ReactFlow> component is as follows:
The React Flow library's documentation strongly recommends passing memoized references to this component, whether the props are functions or objects. To ensure optimal performance, follow these two rules:
Additionally, memoized objects and functions must have stable dependencies to ensure consistent behavior.
If you include elements with frequently changing references (e.g., functions not wrapped in useCallback) in the dependencyArray of useMemo or useCallback, the memoization will not yield the expected results.
Benchmark
Let’s consider the following code modification that illustrates this issue:
Introducing an anonymous function to onNodeClick prop forces React to assign a new reference in every render.
The results (number of FPS) for dragging operation:
The cause
This change caused the re-render of all the diagram's nodes whenever a node's state was updated. It means that with every dragging operation, not only the main <ReactFlow> component and dragged node are being re-rendered but also 99 nodes remaining.
The conclusion
You must remember to properly memoize props, i.e., using useCallback and useMemo, when working with <ReactFlow> component.
Uncontrolled dependencies of components and hooks on node and edge arrays are among the main threats to the performance of applications using ReactFlow.
The state of nodes and edges can change even with minor updates to individual properties of any diagram element. This often results in unnecessary re-renders of components dependent on this state.
The example
Let's assume that you want to display selected nodes' IDs. A quick but not optimal way is:
We fetch the complete node array from the store, filter the selected objects, and display their IDs inside the nodes
The results (number of FPS) for dragging operation:
The cause
The main issue arises from the behavior of the useStore hook: the selectedNodes reference changes with every update of state.nodes (e.g., during every tick of a dragging operation). As a result, all nodes on the diagram defined by this component will re-render with every state update, regardless of whether they are being dragged or not.
The solution
To avoid unnecessary rendering, you can define a field in store where you can keep selected objects. Thanks to that a Node component won’t be directly dependent on nodes array.
In this approach, the selectedNodes variable is refreshed if and only if the selection actually changes, avoiding unnecessary re-renders of the components that depend on it.
An alternative approach
When we want to extract an array of primitive types from a collection of nodes or edges, as shown in the example above, there is also a straightforward solution that uses shallow comparison provided by Zustand.
Zustand offers methods for memoizing selectors either by using the useShallow hook or by creating a store with createWithEqualityFn and passing the shallow parameter. In this example, we use a solution based on store configuration with createWithEqualityFn (see more in Zustand chapter).
Thanks to this approach, the array of primitive types is memoized. Even if its reference changes, the selector will still return the previous reference as long as none of the elements in the array have changed.
The conclusions
Using state from the ReactFlow store in components should be carefully considered, especially when there are dependencies on dynamically changing arrays of nodes or edges.
It's easy to notice issues with suboptimal state usage in a custom node component. However, similar issues can also arise in other scenarios:
One of the most effective ways for keeping a ReactFlow app’s performance is wrapping custom nodes and edges in React.memo.
Thanks to wrapping nodes and edges in a React.memo component, even if listeners on the main ReactFlow component are not used optimally, smaller diagrams are unlikely to experience significant performance issues. This is because the contents of nodes and edges won't re-render during i.e. dragging operation.
Benchmark
Let's test how wrapping in memo impacts performance. To do that, let's restore an anonymous function in the main <ReactFlow> component:
And let’s wrap Node component in React.memo:
The results (number of FPS) for dragging operation:
When compared to the first chapter's test's result without using memo (decrease to 10 FPS for default nodes and to 2 FPS for "heavy" nodes), it is clearly visible that wrapping components in memo significantly improves performance.
It is worth noting that I conducted these tests with developer tools turned off in my browser. These tools can slow down an application and negatively impact the FPS count.
The conclusion
Tests prove that “heavy” nodes (those with more complex components, such as DataGrid from MaterialUI) have a bigger impact on a diagram’s performance.
Having implemented all above-mentioned optimization, the results (number of FPS) for dragging operation present as follows:
The solution
To minimize the impact that Node’s contents have on performance, you can wrap the inside elements in React.memo component. Thanks to that, their rendering will be limited only to these cases, when their props will actually change.
The results (number of FPS) for dragging operation:
The conclusions
If a node has heavy content, such as DataGrid from MaterialUI, the content should be wrapped in React.memo. This will avoid unnecessary re-rendering of its contents during state’s updates.
Zustand is a small, fast and flexible state management library for React applications, used internally by React Flow.
While getting data from the Zustand store, a natural approach would be writing such a code:
Although this code seems intuitive, without a proper store configuration, it can lead to performance issues and, in some cases, errors such as Maximum update depth exceeded.
This happens because the array returned by useStore is recreated from scratch every time the state changes. Since the resulting array has a different reference on each update, it causes component to re-render even if individual values within the array remain unchanged. A similar situation occurs if an object is returned instead of an array—every state change generates a new reference to the object.
Zustand provides the useShallow hook, which memoizes the returned reference if the contents of an array or object have not changed, helping reduce unnecessary re-renders.
The drawback of this method is that useShallow requires remembering to use it every time you get more than one field from the store.
The alternative method is to create a store using the function createWithEquityFn with a shallow parameter. As a result, every selector uses memoization with shallow comparison by default.
Thanks to that, you can use the original approach to useStore hooks.
Fill the form below to get an e-book where you'll find the full version of this guide with two more hints on optimizing the React Flow project's performance and bonus contents on: