Top tips for optimizing React + d3 based charts Apps

Eli Elad Elrom
Feb 18 · 13 min read

Many times, data visualization requires lots of resources to run. For instance, consider the feature that a common chart includes: animations, live data feed updating in runtime, a chart with thousands of records, or few charts placed on a single page.

These requirements can cause a sluggish experience, especially when the user is using less capable devices such as mobile or computer with low memory or a slow network connection.

In this article, I will show you the performance knick-knacks you can do to provide a better user experience.

When building charts, it’s important to give considerations to the footprint we use to ensure we build quality software. In this article, I will be giving you performance tips that focus on improving your React d3 App.

Housekeeping

The article is broken down into different areas;

  • Data loading
  • Install modules instead of global imports
  • Server-side rendering
  • Tree shaking
  • Update DOM only when needed
  • Use CSV over JSON
  • Optimize CRA with Prerender, Prefetching & Precache
  • Memorize function with useCallback

Setup

Let’s set up a project to implement these performance enhancements I will be showing you in this article, using the CRA MHL template;

$ yarn create react-app knick-knacks --template must-have-libraries
$ cd knick-knacks
$ yarn start

Measuring is the key to knowing if your effort produces improvement. Check out my two articles about profile and debugging your App.

Data loading

The core of working with charts is the data. Optimizing the data being sent across the wire can make a significant impact on the time it takes to load the chart.

You should only transmit the fields you need instead of loading the whole kitchen sink. For instance, in one of the previous articles, I have drawn a calendar chart Nivo with a data set of ‘calendar.json’.

That dataset included data from 2018, however, the chart I used was set to use data from 2019–2021 so all that data from 2018 was not needed and it will slow the user experience unnecessarily.

Similarly, when I created a power chart, in the previous article, I used the ‘power_network.json’ that included the field with the color of the node. That is not needed, we can change that to ‘type’ and create an enumeration class that points to the type that we can then use later, in the code. These small measurements can decrease the data size and increase performance.

export enum ColorsTypeEnum {
ONE = ‘#fffff’,
TWO = ‘#00000’
}

The general rule to reduce overhead is avoid duplications.

Similarly, if you send data over the wire, there are many ways to decrease the size and only send what you need. A good example is GraphQL (https://graphql.org/), it gives clients the power to ask exactly what is needed and nothing more.

To measure how long it takes to make these service calls. You can use the browser tools or other 3rd party tools.

For instance, on Chrome. Right-click in the Chrome browser > inspect > Network tab). In the figure below, you can see that the response timing breakdown took 17.39 milliseconds, that’s not much but on a large data set that can be a matter of half a second or seconds that the user has to wait for the chart to load.

Figure 1. Chrome network response timing breakdown for Calendar.json.

Lastly, there are three quick cardinal rules when working with services, as recently shown by https://catchjs.com/Blog/PerformanceInTheWild after rendering a million web pages;

  1. Make as few requests as possible — keeping a low number of requests is more important than the number of kilobytes transferred and that goes for any resources. That was proven by performance tests.
  2. HTTP 3 over HTTP2 and avoid HTTP. HTTP 3 is the best option and it’s about 100x more common. How come? Because most sites linking the same resources such as analytics.js, fbevents.js
  3. Async over blocking requests— Use Async and avoid blocking requests as much as possible.

Install modules instead of global imports

D3 (ver 4 and up) as well as many other libraries, it is possible to import modules instead of the entire library. Doing that can reduce the bundle size need to run the App significantly.

To see this in action, you can analyze the production build, I already set the CRA MHL template with the run script, just install the cra-bundle-analyzer as developer dependency;

$ yarn add --dev cra-bundle-analyzer

Now, you run analyze tool and check for yourself, see Figure 2;

$ yarn analyzer
Figure 2. CRA MHL template initial bundle size.

As you can see the parsed treemap size of CRA MHL is 217.05 kb. That includes React and React DOM v17 (129.17KB) as well as Recoil (54 kb).

To better understand what the different sizes stand for, take a look below:

· Stat size — the size of the input, after webpack bundling, but before optimizations (such as minification).

· Parsed size — the size of the file on disk after optimizations. It is the effective size of the JavaScript code parsed by the client browser

· gzip size — the size of the file after gzip is usually transmitted over the network. Keep in mind that the gzip will need to unzip once reached the client (browser).

First, let’s create a simple React d3 code that will draw a Rectangle. I am using a template I set for function component, but you can just create that on your own;

$ npx generate-react-cli component Rectangle --type=d3

Next, let’s install both the d3 global library as well as only the module we need (d3-selection);

$ yarn add d3-selection @types/d3-selection
$ yarn add d3 @types/d3

If we create the same code just changing it, to first use the global d3 library and then use the d3-selection module we are using. The code is almost identical, however, the footprint changes, take a look.

Here is the version of the Rectangle.tsx function component with importing the entire d3 library;

// src/component/Rectangle/Rectangle.tsx

import React
, { useEffect, RefObject } from 'react'
import * as d3 from 'd3'

const Rectangle
= () => {
const ref: RefObject<HTMLDivElement> = React.createRef()

useEffect(() => {
draw()
})

const draw = () => {
d3.select(ref.current).append('p').text('Hello World')
d3.select('svg')
.append('g')
.attr('transform', 'translate(250, 0)')
.append('rect').attr('width', 500)
.attr('height', 500)
.attr('fill', 'tomato')
}

return (
<div className="Rectangle" ref={ref}>
<svg width="500" height="500">
<g transform="translate(0, 0)">
<rect width="500" height="500" fill="green" />
</g>
</svg>
</div>
)
}

export default Rectangle

Run again analyzer to check the bundle size;

$ yarn analyzer
Figure 3. d3 global library parsed size.

As you can see d3 takes 37.57 parsed.

Now, let’s change the code to only include the ‘d3-selection’ module, which we using since we don’t need any other code from d3;

// src/component/Rectangle/Rectangle.tsx

import React
, { useEffect, RefObject } from 'react'
import { select } from 'd3-selection'

const Rectangle
= () => {
const ref: RefObject<HTMLDivElement> = React.createRef()

useEffect(() => {
draw()
})

const draw = () => {
select(ref.current).append('p').text('Hello World')
select('svg')
.append('g')
.attr('transform', 'translate(250, 0)')
.append('rect').attr('width', 500)
.attr('height', 500)
.attr('fill', 'tomato')
}

return (
<div className="Rectangle" ref={ref}>
<svg width="500" height="500">
<g transform="translate(0, 0)">
<rect width="500" height="500" fill="green" />
</g>
</svg>
</div>
)
}

export default Rectangle

Run again analyzer to check the bundle size. See Figure 4.

As you can see the d3 parsed size was reduced from 37.57 kb to 11.97 kb by using the d3-selection instead of doing a global import. That’s significant!

Figure 4. d3 module library parsed size.

Server-side rendering (SSR)

CRA (SPA) paradigm is great for certain cases because you don’t get page refresh and the experience feels like you are inside of a mobile App.

The pages are meant to be rendered on the client-side. There is another option server-side rendering (SSR).

CRA doesn’t support server-side rendering (SSR) out of the gate. However, there are ways to configure the routing, etc, and get CRA to work as SSR, but that may involve ejecting and maintaining configuration on your own and may not be worth the effort.

ejecting means you’re taking on the responsibility of updating the configuration build code that you may not fully understand. If the build breaks, CRA may not be unable to support the custom configuration you set, and updating the build files may break.

If you’re build something that needs SSR to increase performance, it’s better to just work with a different React library that is already configured out of the box with SSR such as Next.js framework, Razzle, or Gatsby (include a pre-render website into HTML at build time).

Tip: if you want to do server rendering with React and Node.js, check Next.js, Razzle, or Gatsby.

Check my article here to set a minimum starter Nextjs SSL project for d3js & TypeScript project.

Tree shaking

Tree shaking (https://webpack.js.org/guides/tree-shaking/) is a term used in JavaScript context for the removal of dead code. There is much to do when it comes to the removal of the dead code.

When I say dead code, it can be two things:

Never executed code — code that can never be executed during run-time.

The result never used — code is executed but the result is never used.

In my configuration, I have Recoil state management, but I am not using the Recoil feature for anything.

Now, if we dig inside our JS Bundles and see what’s happening, we can see that Recoil is using almost 54.24kb. Figure 5;

Figure 5. Recoil footprint.

If we refactor the code and remove Recoil and build again.

The reason Recoil is even included in my publish build code is that I am using Recoil for a suspense fallback to show a loading message until the component is loaded, but my code loads quickly and that code is not needed.

Open up you can see I am using Recoil inside the React Router;

// src/AppRouter.tsx

import React
, { FunctionComponent, Suspense } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import App from './App'

const AppRouter: FunctionComponent = () => {
return (
<Router>
<RecoilRoot>
<Suspense fallback={<span>Loading...</span>}>
<Switch>
<Route exact path="/" component={App} />
</Switch>
</Suspense>
</RecoilRoot>
</Router>
)
}

Refactornot to use Recoil;

// src/AppRouter.tsx

import React, { FunctionComponent } from 'react'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import App from './App'

const AppRouter: FunctionComponent = () => {
return (
<Router>
<Switch>
<Route exact path="/" component={App} />
</Switch>

</Router>
)
}

You could also remove recoil from your package.json, run the yarn command but if it’s not used that wouldn’t be necessary;

$ yarn remove recoil

Having imports for libraries we are not using increases the size of our code (JS bundles). These imports should be removed;

Run again analyzer and you can see that the bundle went down in size to 175kb.

$ yarn analyzer

Update DOM only when needed

When working with d3 and React, the biggest advantage is that we can let React control the DOM. React VDOM needs to update only once a change is made. We need to ensure we are re-rendering only when a change is needed.

The d3 code is considered a side effect because it adds content to the DOM outside of React’s VDOM mechanism. Because of that, we want to let the VDOM know when to re-draw.

In the function component, hook is used for any side effects and after the component is unmounted any events that are not React-based need to be cleaned to ensure there will be no memory leaks.

But that’s not enough. The other concern we may have is to ensure our chart is only updated when needed, instead of on every component update.

For example. If you look at the HelloD3Data.tsx function component I created below. We pass data through the function props and draw each data string as a label;

// src/component/HelloD3Data/HelloD3Data.tsx

import React
, { useEffect } from 'react'
import './HelloD3Data.scss'
import { select, selectAll } from 'd3-selection'
interface IHelloD3DataProps {
data: string[]
}
const HelloD3Data = (props: IHelloD3DataProps) => {
useEffect(() => {
draw()
})

const draw = () => {
console.log('draw!')
select('.HelloD3Data')
.selectAll('p')
.data(props.data)
.enter()
.append('p')
.text((d) => `d3 ${d}`)
}
return <div className="HelloD3Data" />
}

export default HelloD3Data

Here’s the parent component . The parent component holds the data array string as the state. On a button click, I am changing the state with the same array string value as the original initial values;

import React, { useState } from 'react'
import './App.scss'
import { Button } from '@material-ui/core'
import HelloD3Data from './components/HelloD3Data/HelloD3Data'

function App
() {
const [data, setData] = useState<string[]>(['one', 'two', 'three', 'four'])
return (
<div className="App">
<HelloD3Data data={data} />
<Button onClick={() => setData(['one', 'two', 'three', 'four'])}>

Click
</Button>
</div>
)
}

export default App

Now if we run this code and keep clicking the click button the child component HelloD3Data.tsx will get re-draw on each click.

Figure 6. HelloD3Data.tsx

What happens here is that React call useEffect on every update and since d3 draws the DOM it causes a re-draw of the same code.

What we need to do is check for a data update.

There are few ways to do that, here are three;

  1. Check d3 data — check the data inside of d3 elements
  2. Clone — clone the data locally
  3. React Class component — React class components already have logic built in that passes previous values.

Check d3 data

To check the data inside of our d3 elements we can select all the p elements we using and then iterate through the array to create a previous data object that we can compare;

// src/component/HelloD3Data/HelloD3Data.tsx

import React
, { useEffect } from 'react'
import './HelloD3Data.scss'
import { select, selectAll } from 'd3-selection'

const HelloD3Data = (props: IHelloD3DataProps) => {
useEffect(() => {
draw()
})

const draw = () => {
const previousData: string[] = []
const p = selectAll('p')
p.each((d, i) => {
previousData.push(d as string)
})
if ( JSON.stringify(props.data) !== JSON.stringify(previousData) ) {

console.log('draw!')
select('.HelloD3Data')
.selectAll('p')
.data(props.data)
.enter()
.append('p')
.text((d) => `d3 ${d}`)
}
}
return (
<div className="HelloD3Data">
</div>
)
}

interface IHelloD3DataProps {
data: string[]
}

export default HelloD3Data

With that data check in place, we can update the results and not worry about the DOM re-draw our elements more than needed;

<Button onClick={() => setData(['one', 'two', 'three', 'four', 'five'])}>
Click
</Button>

Clone data

The second approach is to clone the data and then we can compare the props value with our state value;

// src/component/HelloD3DataCloned/HelloD3DataCloned.tsx

import React
, { RefObject, useEffect, useState } from 'react'
import './HelloD3Data.scss'
import { select } from 'd3-selection'

const ref: RefObject<HTMLDivElement> = React.createRef()

const HelloD3DataCloned = (props: IHelloD3DataProps) => {

const [data, setData] = useState<string[]>([])

useEffect(() => {
if (JSON.stringify(props.data) !== JSON.stringify(data)){
setData(props.data)
// eslint-disable-next-line no-console
console.log('draw!')
select(ref.current)
.selectAll('p')
.data(data)
.enter()
.append('p')
.text((d) => `d3 ${d}`)
}
}, [data, props.data, setData])

return <div className="HelloD3Data" ref={ref} />
}

interface IHelloD3DataProps {
data: string[]
}

export default HelloD3DataCloned

This approach is great but it is less desirable than checking inside of d3 since we now have the same data being stored in memory three times (props, state, HTML element).

With that said, cloning data is a sometimes necessary step, since d3 logic changes data inside of an object and React props would not tolerate that and TypeScript will actually going to spit out an error message. ‘TypeError: Cannot add property index, the object is not extensible’

React Class component

The third option is to create a class component instead of a function component since it already has the lifecycle hooks to handle when the component mount and updated and we can just use that to compare the previous props data with the current one;

// src/component/HelloD3DataClass/HelloD3DataClass.tsx

import React
, { RefObject } from 'react'
import { select } from 'd3-selection'

export default class HelloD3DataClass extends React.PureComponent<IHelloD3DataClassProps, IHelloD3DataClassState> {
ref: RefObject<HTMLDivElement>

constructor(props: IHelloD3DataClassProps) {
super(props)
this.ref = React.createRef()
}

componentDidMount() {
this.draw()
}

componentDidUpdate(prevProps: IHelloD3DataClassProps, prevState: IHelloD3DataClassState) {
if (JSON.stringify(prevProps.data) !== JSON.stringify(this.props.data)) {
this.draw()
}
}

draw = () => {
// eslint-disable-next-line no-console
console.log('draw!')
select(this.ref.current)
.selectAll('p')
.data(this.props.data)
.enter()
.append('p')
.text((d) => `d3 ${d}`)
}

render() {
return (
<div className="HelloD3DataClass" ref={this.ref} />
)
}
}

interface IHelloD3DataClassProps {
data: string[]
}

interface IHelloD3DataClassState {
// TODO
}

As you can see all options are valid and allow us to control the d3 update only when data is changed to avoid un-necessary DOM update constantly.

Notice that I used over since it gives a performance boost in some cases in exchange for losing lifecycle event. and are preferred over .

Use CSV over JSON

When creating d3 charts, it’s common to use CSV and JSON for the data feed.

Keep in mind, if you have the choice in the matter, CSV is preferred over JSON.

  • CSV uses less bandwidth — CSV uses the character separator and JSON needs more characters just for the syntax format.
  • CSV Process data faster — CSV character separator is quicker to split, and in JSON the syntax needs to be interpreted.

Optimize CRA with Prerender, Prefetching & Precache

If you running your React d3 code with CRA. Check Prerender, Prefetching & Precache, in a separate article I have here on Medium with more performance enhancements tips that are good to implement on any React App. These will apply here as well especially when the Chart is on its own “page”.

Memorize function with

I showed you before how to use useEffect and draw. Take a look;

useEffect(() => {
draw()
}
const draw = () => {
// TODO
}

However, that code is problematic, this code can cause an infinite loop! can help prevent this.

can be used withto help prevent the re-creation of functions.

https://reactjs.org/docs/hooks-reference.html#usecallback

useEffect(() => {
memoizedDrawCallback()
}, [memoizedDrawCallback]
const memoizedDrawCallback = useCallback(() => {
// TODO using data as dependency
}, [data])

As you can see functions such as are nothing more than objects in JS and wrapping them around a function declaration and defining the dependencies of the function can ensure the function only re-created if its dependencies changed. For example list data or props that you need in the dependencies array;

[props.bottom, props.data, props.fill, props.height, props.left, props.right, props.top, props.width]

function will not get re-built on every render cycle update and we break out of a potential infinite loop!

Summary

In this article, I covered top performance knick-knacks techniques you can use to optimize your React d3 chart.

The article was broken down into different areas;

  • Data loading
  • Install modules instead of global imports
  • Server-side rendering
  • Tree shaking
  • Update DOM only when needed
  • Use CSV over JSON
  • Optimize CRA with Prerender, Prefetching & Precache
  • Memorize function with useCallback

Applying these methods and measuring the results can help reduce the application footprint and the load time in precious seconds.

Master React

Curated Hands-on tutorials to help you learn React and related ReactJS Libraries.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store