Universal component for graphs on React + D3.js

Hey! My name is Lenya, I’m a front-end developer in hh.ru. Once we were drawing charts in React using the D3.js library and ran into a problem. We needed to add functionality to our existing component that did not fit into the current implementation at all. It was easier to write a similar component side by side than to modify the old one. An article about how we were looking for a solution for a universal graph component and trying different ways to pass data to React.

How it was

First, we drew this chart, it was a regular LineChart. Dates are on the OX axis, some values ​​are on the OY axis.

All this is quite simply done by standard means. D3.js. We didn’t have any difficulties. Then the business client asked me to draw not one, but two lines on the same chart. And the designer did his best and drew all sorts of beauties: key points, pop-up tooltips, gradients, etc.

At that time, we did not have time to rewrite the old chart component, so we wrote another one next to it.

And then the situation repeated itself again. And as a result, we got as many as three similar components for drawing line charts. We lived with them for almost two years. Then the business again came to us and asked us to draw more charts. This time we were in no hurry and decided to refactor the old code: get rid of copy-paste and make a convenient, scalable component.

List of requirements

In two years of use, we found out the basic requirements that our component for graphs must satisfy. Before we started refactoring, we made a list of them.

Here’s what we got:

  • The graph must be rubber in width and fixed in height;

  • You can draw multiple lines on one chart;

  • The lines should be easy to set up. We should be able to set their color, style, gradient, and other properties;

  • We should be able to draw chart axes and labels. Or not draw them at all;

  • Hovering over the chart should display a tooltip with information about the nearest point;

  • I also wanted to draw additional entities: it could be a grid on the chart, some additional key points, filled areas, etc.

We also understood how our component should look from a technical point of view so that it is convenient and easily extensible. There should be a LineChart parent component, into which we pass a list of points and other additional properties as props. Further, in this component, we nest individual elements of the chart: lines, axes, tooltips, and more. Each such element can have its own props.

<LineChart data={data} height={300}>
    <Line gradient />
    <Axis axisName={AxisName.X} position={AxisPosition.Bottom} />
    <Axis axisName={AxisName.Y} position={AxisPosition.Left} />
    <Tooltip />
</LineChart>

Solution

First, for D3 it is necessary to prepare the data. Secondly, based on them and the size of the graph, you need to make functions for creating axes. And thirdly, all this must be thrown into child components.

// под капотом getPreparedData, getXAxis, getYAxis нативные методы D3.js
const chartData = getPreparedData(data);
const xAxis = getXAxis(chartData, width);
const yAxis = getYAxis(chartData, heigh);

<LineChart height={300}>
    <Line
        gradient
        data={chartData}
        xAxis={xAxis}
        yAxis={yAxis}
    />
    <Axis
        axisName={AxisName.X}
        position={AxisPosition.Bottom}
        data={chartData}
        xAxis={xAxis}
        yAxis={yAxis}
    />
    <Axis
        axisName={AxisName.Y}
        position={AxisPosition.Left}
        data={chartData}
        xAxis={xAxis}
        yAxis={yAxis}
    />
    <Tooltip
        data={chartData}
        xAxis={xAxis}
        yAxis={yAxis}
        width={width}
        height={height}
    />
</LineChart>

The solution “on the forehead” turned out to be not very successful. With this approach, the data preprocessing logic is in the external component and will be repeated from time to time when using the component. We wanted to encapsulate the logic in the LineChart itself. Which is what we did.

const LineChart = ({ data, width, height, children }) => {
  const chartData = getPreparedData(data);
  const xAxis = getXAxis(chartData, width);
  const yAxis = getYAxis(chartData, height);
  
  return <svg>{children}</svg>
}

However, now it was necessary to somehow pass this data to the child components. The first thing we thought about was the render function.

const LineChart = ({ data, width, height, renderContent }) => {
  const chartData = getPreparedData(data);
  const xAxis = getXAxis(chartData, width);
  const yAxis = getYAxis(chartData, height);
  
  return <svg>{renderContent(chartData, xAxis, yAxis)}</svg>
}
  
<LineChart
    data={data}
    width={1000}
    height={300}
    renderContent={(chartData, xAxis, yAxis) => (
        <>
            <Line
                gradient
                data={chartData}
                xAxis={xAxis}
                yAxis={yAxis}
            />
            <Axis
                axisName={AxisName.X}
                position={AxisPosition.Bottom}
                data={chartData}
                xAxis={xAxis}
                yAxis={yAxis}
            />
						// ...
      </>
  )}
/>

Thus, the internal components got access to the data, but they still have to be manually passed to the child components each time. It’s not very convenient.

It would be cool if the LineChart component added common props to child components. And, for example, React.cloneElement can help us with this. The result is this code:

<LineChart
    data={data}
		width={1000}
    height={300}
    LineComponent={<Line color={Color.Green} />}
    AxisComponent={<Axis />}
    TooltipComponent={<Tooltip />}
/>


const LineChart = ({
  data, width, height, LineComponent, AxisComponent, TooltipComponent
}) => {
  const chartData = getPreparedData(data);
  const xAxis = getXAxis(chartData, width);
  const yAxis = getYAxis(chartData, heigh);
  
    return (
        <>
          {React.cloneElement(LineComponent, {chartData, xAxis, yAxis})}
          {React.cloneElement(AxisComponent, {chartData, xAxis, yAxis})}
					{React.cloneElement(TooltipComponent, {chartData, xAxis, yAxis})}
          // ...
        </>
    )
}

Logic is encapsulated, common props are rolled automatically. But using the component was still not convenient, because for each new entity on the chart, a separate prop had to be created in the parent component.

We began to look for another solution and remembered React Context. We rewrote the code and this is what happened:

const LineChart = ({ data, width, height }) => {
    const chartData = getPreparedData(data);
    const xAxis = getXAxis(chartData, width);
    const yAxis = getYAxis(chartData, height);
  
    return (
        <ChartContext.Provider value={{ chartData, xAxis, yAxis }}>
            <svg>{children}</svg>
        </ChartContext.Provider>
    )
}

<LineChart data={data} width={1000} height={300}>
    <Line gradient />
    <Axis axisName={AxisName.X} position={AxisPosition.Bottom} />
    <Axis axisName={AxisName.Y} position={AxisPosition.Left} />
    <Tooltip />
</LineChart>

The data preprocessing logic is still inside the LineChart. With the help of React Сontext, we forward the necessary data to child components. The child components themselves, for example, the Line component for drawing lines, have both their own independent props and use data from the context.

const Line = ({ color }) => {
  const { chartData } = useContext(ChartContext);
  
  return (
      <path stroke={color} d={chartData.linePath} />
  );
};

Thus, we got exactly the format of the component that we wanted. In addition, it is easy to add new entities to it, which will automatically access the data necessary for rendering on D3.js.

Results

Perhaps we did not succeed in making a universal component for charts for all occasions. It is possible that one day a business will come to us again and ask us to draw many more different things on the chart. For example, a red line in a transparent color or a line in the form of a kitten. Well, then we’ll do something about it.

But, of course, we managed to make our component more flexible and versatile. In addition, during the refactoring, we got rid of repetitive code, considered various implementation options, their pros and cons, and better understood how to use D3.js.

From my experience, I can recommend not to create a universal component for all occasions at once. There is a high chance that you will not be able to predict absolutely everything and you will still have to rewrite your code. It’s better to live with what you have for a while. Gather business and technical requirements. And on the basis of this, think over the architecture and refactor.

That’s all for me. Tell us about your own solutions to similar problems in the comments. It would be interesting to know what you use for graphs in your projects.

Thank you!

Similar Posts

Leave a Reply