It's no secret that the rules on pure React solutions are pretty simple. We just need to properly use all React inventory like useState, useEffect, useMemo, and useCallback. There are many intelligent articles, guides, and examples on the topic. But let's answer the following question.
How many "pure" projects did you tackle?
A pure React (Angular, NodeJS, etc.) project could look like nonsense in real life. Customers expect complicated solutions, including different 3-rd party stuff like Payment Systems, Graphical Libraries, CRM integrations, Tracking Tools, etc. Obviously, not all of them are React-friendly, so we should count these libraries' features in most cases and try to perfect React code simultaneously.
Today I want to tell you some performance specifics across React and D3.
I wrote several articles on the D3 topics before, and I guess it will also be interesting for you. There are the following related articles.
But let's focus on the current topic.
D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS. D3’s emphasis on web standards gives you the full capabilities of modern browsers without tying yourself to a proprietary framework, combining powerful visualization components and a data-driven approach to DOM manipulation.
D3 is great! I'm fond of this beautiful library. But it lives its own life. That's why we need to remember this fact when we work with D3 outside Vanilla JS, say, in React.
The Objective
The goal is a simple D3 line chart implemented with dynamic guideline movement every second.
I'm pretty sure the best way to understand what's good is to explain what's wrong. That's why I will start my solutions from the worst example. I'm also going to explain why the example is so wrong, and after that, I'll propose to you the best way of implementation.
The Worst Solution
There is the following component represents a line chart.
import React, { useEffect, useRef } from "react";
import * as d3 from "d3";
const transform = "translate(50,50)";
export default function LineChart({ data, width, height, marker }) {
const svgRef = useRef();
const renderSvg = () => {
const chartWidth = width - 200;
const chartHeight = height - 200;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const xScale = d3.scaleLinear().domain([0, 100]).range([0, chartWidth]);
const yScale = d3.scaleLinear().domain([0, 200]).range([chartHeight, 0]);
const g = svg.append("g").attr("transform", transform);
g.append("g")
.attr("transform", "translate(0," + chartHeight + ")")
.call(d3.axisBottom(xScale));
g.append("g").call(d3.axisLeft(yScale));
svg
.append("g")
.selectAll("dot")
.data(data)
.enter()
.append("circle")
.attr("cx", function (d) {
return xScale(d[0]);
})
.attr("cy", function (d) {
return yScale(d[1]);
})
.attr("r", 3)
.attr("transform", transform)
.style("fill", "#CC0000");
const line = d3
.line()
.x(function (d) {
return xScale(d[0]);
})
.y(function (d) {
return yScale(d[1]);
})
.curve(d3.curveMonotoneX);
svg
.append("path")
.datum(data)
.attr("class", "line")
.attr("transform", transform)
.attr("d", line)
.style("fill", "none")
.style("stroke", "#CC0000")
.style("stroke-width", "2");
if (marker) {
svg
.append("svg:line")
.attr("transform", transform)
.attr("stroke", "#00ff00")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 2)
.attr("x1", xScale(marker))
.attr("y1", 200)
.attr("x2", xScale(marker))
.attr("y2", 0);
}
};
useEffect(() => {
renderSvg();
}, [width, height, data, marker]);
if (!width || !height || !data) {
return <></>;
}
return <svg ref={svgRef} width={width} height={height} />;
}
This component takes the following props.
-
data
- chart data as a two-dimensional array of x and y -
width
- with of the chart -
height
- height of the chart -
marker
- X axis of a guideline
And there is a related parent component.
import React, { useState, useEffect } from "react";
import LineChart from "./LineChart";
import "./style.css";
const data = [
[1, 1],
[12, 20],
[24, 36],
[32, 50],
[40, 70],
[50, 100],
[55, 106],
[65, 123],
[73, 130],
[78, 134],
[83, 136],
[89, 138],
[100, 140],
];
export default function App() {
const [marker, setMarker] = useState(10);
useEffect(() => {
const intervalId = setInterval(() => {
setMarker((prevMarker) => (prevMarker + 10 > 100 ? 10 : prevMarker + 10));
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
return (
<div id="root-container">
<LineChart data={data} width={500} height={400} marker={marker} />
</div>
);
}
There is an interval refresh a marker value every second and pass it as a chat's prop.
You can play with the complete example here.
It seems it's nothing foreshadowing the issue. I want to modify the code above. The aim is to show the issue eloquently.
The Number of Renders
First, I'll add a global renders
and timeStart
variables to public/index.html
<script>
var renders = 0;
var timeStart = new Date().toISOString();
</script>
<div id="root"></div>
Second, I increase renders
every LineChart
render.
export default function LineChart({ data, width, height, marker }) {
// no changes here ...
renders++;
useEffect(() => {
renderSvg();
}, [width, height, data, marker]);
if (!width || !height || !data) {
return <></>;
}
return <svg ref={svgRef} width={width} height={height} />;
}
And finally, I changed the parent component the following way.
export default function App() {
const [marker, setMarker] = useState(10);
useEffect(() => {
const intervalId = setInterval(() => {
setMarker((prevMarker) => (prevMarker + 10 > 100 ? 10 : prevMarker + 10));
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
const currentTime = new Date().toISOString();
return (
<div id="root-container">
<div style={{ marginTop: 20, marginLeft: 20 }}>
renders: {renders}
<br />
start: {timeStart}
<br />
now: {currentTime}
</div>
<LineChart data={data} width={500} height={400} marker={marker} />
</div>
);
}
The main goal is to display three metrics: the number of renderings, start time, and current time.
Let's run the modified example.
As far as we can see, each marker change causes LineChart
component to render. If the result above doesn't persuade you, I have prepared the experiment below. I left the working example for a few minutes and drank coffee.
When I returned, I saw the following.
948
render per cup of coffee! Looks awful...
Moreover, a bunch of D3 heavyweight operations covers each render!
The Best Solution
It's time to fix the issue above.
First, let me provide you the final LineChart version and explain what's changed there step by step.
import React, {
useEffect,
forwardRef,
useImperativeHandle,
useRef,
} from "react";
import * as d3 from "d3";
const transform = "translate(50,50)";
const LineChart = forwardRef(({ data, width, height }, ref) => {
const svgRef = useRef();
let svg;
let xScale;
useImperativeHandle(ref, () => ({
setMarker: (value) => {
if (isNaN(value)) {
return;
}
svg.selectAll(".marker").remove();
svg
.append("svg:line")
.attr("transform", transform)
.attr("class", "marker")
.attr("stroke", "#00ff00")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 2)
.attr("x1", xScale(value))
.attr("y1", 200)
.attr("x2", xScale(value))
.attr("y2", 0);
},
}));
const renderSvg = () => {
const chartWidth = width - 200;
const chartHeight = height - 200;
svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
xScale = d3.scaleLinear().domain([0, 100]).range([0, chartWidth]);
const yScale = d3.scaleLinear().domain([0, 200]).range([chartHeight, 0]);
const g = svg.append("g").attr("transform", transform);
g.append("g")
.attr("transform", "translate(0," + chartHeight + ")")
.call(d3.axisBottom(xScale));
g.append("g").call(d3.axisLeft(yScale));
svg
.append("g")
.selectAll("dot")
.data(data)
.enter()
.append("circle")
.attr("cx", function (d) {
return xScale(d[0]);
})
.attr("cy", function (d) {
return yScale(d[1]);
})
.attr("r", 3)
.attr("transform", transform)
.style("fill", "#CC0000");
const line = d3
.line()
.x(function (d) {
return xScale(d[0]);
})
.y(function (d) {
return yScale(d[1]);
})
.curve(d3.curveMonotoneX);
svg
.append("path")
.datum(data)
.attr("class", "line")
.attr("transform", transform)
.attr("d", line)
.style("fill", "none")
.style("stroke", "#CC0000")
.style("stroke-width", "2");
};
renders++;
useEffect(() => {
renderSvg();
}, [width, height, data]);
if (!width || !height || !data) {
return <></>;
}
return <svg ref={svgRef} width={width} height={height} />;
});
export default LineChart;
forwardRef
Now LineChart's parent is able to work with the related component reference.
const LineChart = forwardRef(({ data, width, height }, ref) => {
useImperativeHandle
During some interviews, I ask my interviewees this question. I'm surprised because most of them can't answer it. In my opinion, this hook is as important as the basic like useState and useEffect because it makes your code more flexible and performative.
Here is the exposed code.
useImperativeHandle(ref, () => ({
setMarker: (value) => {
if (isNaN(value)) {
return;
}
svg.selectAll(".marker").remove();
svg
.append("svg:line")
.attr("transform", transform)
.attr("class", "marker")
.attr("stroke", "#00ff00")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("stroke-width", 2)
.attr("x1", xScale(value))
.attr("y1", 200)
.attr("x2", xScale(value))
.attr("y2", 0);
},
}));
I moved it from the end of renderSvg
function. See the previous example.
Let's focus on the parent component. Please, read comments there.
import React, { useState, useEffect, useMemo, useRef } from 'react';
import LineChart from './LineChart';
import './style.css';
const data = [
// no changes
];
export default function App() {
const [marker, setMarker] = useState(10);
// Provide a reference for LineChart
const chartRef = useRef();
useEffect(() => {
// If the marker has been changed set it on LineChart directly, see useImperativeHandle
chartRef.current.setMarker(marker);
}, [marker]);
useEffect(() => {
const intervalId = setInterval(() => {
setMarker((prevMarker) => (prevMarker + 10 > 100 ? 10 : prevMarker + 10));
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
const currentTime = new Date().toISOString();
// There is a trick because we don't need to render LineChart after every App state variable change
// As you can see we don't pass the marker here.
const chart = useMemo(() => {
return <LineChart ref={chartRef} data={data} width={500} height={400} />;
}, [data]);
return (
<div id="root-container">
<div style={{ marginTop: 20, marginLeft: 20 }}>
renders: {renders}
<br />
start: {timeStart}
<br />
now: {currentTime}
</div>
{chart}
</div>
);
}
According to the comments above, there are three points of change.
- Provide a reference for LineChart
- Marker direct setting via
useImperativeHandle.
Pay attention to the fact that every useImperativeHandle-based call doesn't cause the component to render. It's super important! - Memoise the
LineChart
component. We don't need to refresh it with eachApp
state change.
Finally, the most tricky stuff has remained.
After attentional looks at the code above, you could ask a question.
On the one hand, now the component shouldn't be re-rendered. On the other hand, the guideline moves from point A to point B. Of course,
chartRef.current.setMarker(marker);
direct call allows us to set the guideline in the new position. But what approach allows us to remove the previous guideline from point A?
At the start of the article, I meant that we need to count D3 library features. In this case, we should know two facts below.
- D3 objects are stateful, so we can operate them whenever needed. In this context, please look at the following code.
let svg;
const renderSvg = () => {
// ...
svg = d3.select(svgRef.current);
//All futures results of modifications will be present persistently in SVG object
};
- According to the feature above, we can change the D3 object every time without re-rendering. Moreover, we can manipulate different chart parts via fake CSS classes.
Look at the following code.
setMarker: (value) => {
if (isNaN(value)) {
return;
}
svg.selectAll('.marker').remove();
svg
.append('svg:line')
.attr('transform', transform)
.attr('class', 'marker')
.attr('stroke', '#00ff00')
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.attr('stroke-width', 2)
.attr('x1', xScale(value))
.attr('y1', 200)
.attr('x2', xScale(value))
.attr('y2', 0);
},
}));
When we add a guideline, we add a special fake class into it:
.attr('class', 'marker')
But before we remove the previous guideline via
svg.selectAll('.marker').remove();
That's all for today about the secrets of D3.
It's time to run the final example! You can play with the complete final example here.
Only two renders
per all time. Looks cool!
That’s like music to the ears of React developer!
Happy coding!
PS: If you are wondering why two renders, please read about React Strict Mode.
Top comments (0)