React + D3 Sunburst Chart ☀️
Tags: react, d3, chart, javascript
Published: 2021-09-21 05:47:37

In this post, I am going to show you how you can build a sunburst chart (or any chart) using React and D3.

## Power of D3 and React 💪

D3 is the king of data visualisation. It appeared around 10 years ago and there are still not so many libraries that can compete with it.

What is more, most of JavaScript data visualisations libraries are built on top of D3, because it is low level and can be customized however you want.

### React and D3 integration

If you look into D3 code samples you might notice that it looks similar to... Yes, jQuery! It is not only visualization library but **JavaScript library for manipulating documents based on data**.

There are 3 ways of integrating React and D3:
- D3-oriented approach: D3 manages the chart
- React-oriented approach: React manages the chart
- Hybrid approach: React for element creation, D3 for updates

[More info](https://www.createwithdata.com/integrating-react-and-d3)

One of the key benefits of managing the chart using D3 is that we can easily add transitions, but in this tutorial, we would rely on a React-oriented approach as we would not need transitions (at least yet 🌚).

![React.js + D3.js meme](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/84xumh7igqm28kwfquc0.jpg)

### Why not use existing React based component libraries?

Actually, you can (maybe you even should). There are many existing libraries with great API that would allow you creating different charts with low effort.

However, sometimes you might get stuck if that library doesn't support the feature (or chart) you want.

If you want to have full control over your visualisation then you should do it using D3.

## Building sunburst chart 👨🏼‍💻

I know that many of you prefer to dive right into the code. 

Here is codesandbox with full code for this tutorial:

{% codesandbox sunburst-react-for-rendering-ioop1 %}


### Finding D3 sunburst chart code

Cool thing about D3 is that it has hundreds of visualisations with code for it. All you need to do is just google it:
![Screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bvlbxtf5e4m2yao9pezt.png)

We would use the second link as it is a simpler example: https://observablehq.com/@d3/sunburst

![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a8waazplb0p0i8to9irt.png)

This code might scare you in the beginning but it is okay. You don't have to understand every line of it. Our goal is to integrate it into React.

### Basic setup

Building our chart would start with adding svg ref:

```typescript
import React from "react";

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

  return <svg ref={svgRef} />;
};
```

We are going to add `width` (we will name it `SIZE`) and `radius` (we will name it `RADIUS`) from code sample.

![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d34yw10ucj2jlf28ejqn.png)

```diff
import React from "react";
+ const SIZE = 975;
+ const RADIUS = SIZE / 2;

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

-  return <svg ref={svgRef} />;
+  return <svg width={SIZE} height={SIZE} ref={svgRef} />;
};
```

This chart uses json data and we are going to download it and add into our app.

![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rr7d724l07i3cy98coea.png)
 
```diff
import React from "react";
+ import data from "./data.json";
const SIZE = 975;
const RADIUS = SIZE / 2;

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

  return <svg width={SIZE} height={SIZE} ref={svgRef} />;
};
```

### D3 manages the chart

Let's install `d3` and `@types/d3`.

```bash
npm install d3 @types/d3
```

When installation is finished, we will put all chart setup code into `useEffect` with little modifications
![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v7y056ub7czgrkt8kqti.png)

```diff
import React from "react";
import data from "./data.json";
+ import * as d3 from "d3";

const SIZE = 975;
const RADIUS = SIZE / 2;

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);
+  
+  React.useEffect(() => {
+    const root = partition(data);
+
//   We already created svg element and will select its ref
-    const svg = d3.create("svg");
+    const svg = d3.select(svgRef.current);
+
+    svg
+      .append("g")
+      .attr("fill-opacity", 0.6)
+      .selectAll("path")
+      .data(root.descendants().filter((d) => d.depth))
+      .join("path")
+      .attr("fill", (d) => {
+        while (d.depth > 1) d = d.parent;
+        return color(d.data.name);
+      })
+      .attr("d", arc)
+      .append("title")
+      .text(
+        (d) =>
+          `${d
+            .ancestors()
+            .map((d) => d.data.name)
+            .reverse()
+            .join("/")}\n${format(d.value)}`
+      );
+
+    svg
+      .append("g")
+      .attr("pointer-events", "none")
+      .attr("text-anchor", "middle")
+      .attr("font-size", 10)
+      .attr("font-family", "sans-serif")
+      .selectAll("text")
+      .data(
+        root
+          .descendants()
+          .filter((d) => d.depth && ((d.y0 + d.y1) / 2) * 
+          (d.x1 - d.x0) > 10)
+      )
+      .join("text")
+      .attr("transform", function (d) {
+        const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI;
+        const y = (d.y0 + d.y1) / 2;
+        return `rotate(${
+          x - 90
+        }) translate(${y},0) rotate(${x < 180 ? 0 : 180})`;
+      })
+      .attr("dy", "0.35em")
+      .text((d) => d.data.name);
+
//   We don't need to return svg node anymore
-    return svg.attr("viewBox", getAutoBox).node();
+    svg.attr("viewBox", getAutoBox);
+  }, []);

   return <svg width={SIZE} height={SIZE} ref={svgRef} />;
};
```

Nice! Let's add missing functions:
![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/puph35bc17m3m8cnktfy.png)
![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/egcp57cn5avg5smz2b1h.png)

```diff
...

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);
+
+  const partition = (data) =>
+    d3.partition().size([2 * Math.PI, RADIUS])(
+      d3
+        .hierarchy(data)
+        .sum((d) => d.value)
+        .sort((a, b) => b.value - a.value)
+    );
+
+  const color = d3.scaleOrdinal(
+    d3.quantize(d3.interpolateRainbow,data.children.length+1)
+  );
+
+  const format = d3.format(",d");
+
+  const arc = d3
+    .arc()
+    .startAngle((d) => d.x0)
+    .endAngle((d) => d.x1)
+    .padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
+    .padRadius(RADIUS / 2)
+    .innerRadius((d) => d.y0)
+    .outerRadius((d) => d.y1 - 1);
+ 
// Custom autoBox function that calculates viewBox
// without doing DOM manipulations
-  function autoBox() {
-    document.body.appendChild(this);
-    const {x, y, width, height} = this.getBBox();
-    document.body.removeChild(this);
-    return [x, y, width, height];
-  }
+  const getAutoBox = () => {
+    if (!svgRef.current) {
+      return "";
+    }
+
+    const { x, y, width, height } = svgRef.current.getBBox();
+
+    return [x, y, width, height].toString();
+  };
+
  React.useEffect(() => {
    ...
```

At this point, we should see our chart:

{% codesandbox kjee6 %}

Beautiful, isn't it? But it is not finished yet. We append chart elements using D3, but we don't handle updating it or cleaning it up. 

We can do it in `useEffect` hook as well and let D3 manage it, but we will do it in React oriented way.

### React manages the chart

To have a better developing experience and avoid bugs we are going to fix types issues before we move on.

```diff
...

+ interface Data {
+  name: string;
+  value?: number;
+ }

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

  const partition = (data: Data) =>
-    d3.partition().size([2 * Math.PI, RADIUS])(
+    d3.partition<Data>().size([2 * Math.PI, RADIUS])(
      d3
        .hierarchy(data)
        .sum((d) => d.value)
        .sort((a, b) => b.value - a.value)
    );

...

  const arc = d3
-   .arc()
+   .arc<d3.HierarchyRectangularNode<Data>>()
    .startAngle((d) => d.x0)
    .endAngle((d) => d.x1)
    .padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
    .padRadius(RADIUS / 2)
    .innerRadius((d) => d.y0)
    .outerRadius((d) => d.y1 - 1);

...
```

#### Remove append function and put everything in render

This part is a bit difficult and might require a bit of D3 understanding. What I like to do is to inspect svg element throw DevTools and slowly move everything in render.

![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/up1d8ky4rkzc0vutukg1.png)

As you can see we have 2 groups. The first group keeps all paths and the other one keeps text elements.

![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q5gmqqpjxcem11crn2z2.png)
![screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9kvq71hwe6br3b1ips74.png)

And we are going to repeat the same structure 😉

```diff
...

  React.useEffect(() => {
    const root = partition(data);

    const svg = d3.select(svgRef.current);
-
-    svg
-      .append("g")
-      .attr("fill-opacity", 0.6)
-      .selectAll("path")
-      .data(root.descendants().filter((d) => d.depth))
-      .join("path")
-      .attr("fill", (d) => {
-        while (d.depth > 1) d = d.parent;
-        return color(d.data.name);
-      })
-      .attr("d", arc)
-      .append("title")
-      .text(
-        (d) =>
-          `${d
-            .ancestors()
-            .map((d) => d.data.name)
-            .reverse()
-            .join("/")}\n${format(d.value)}`
-      );
-
-    svg
-      .append("g")
-      .attr("pointer-events", "none")
-      .attr("text-anchor", "middle")
-      .attr("font-size", 10)
-      .attr("font-family", "sans-serif")
-      .selectAll("text")
-      .data(
-        root
-          .descendants()
-          .filter((d) => d.depth && ((d.y0 + d.y1) / 2) * 
-          (d.x1 - d.x0) > 10)
-      )
-      .join("text")
-      .attr("transform", function (d) {
-        const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI;
-        const y = (d.y0 + d.y1) / 2;
-        return `rotate(${
-          x - 90
-        }) translate(${y},0) rotate(${x < 180 ? 0 : 180})`;
-      })
-      .attr("dy", "0.35em")
-      .text((d) => d.data.name);

    svg.attr("viewBox", getAutoBox);
  }, []);
+
+ const getColor = (d: d3.HierarchyRectangularNode<Data>) => {
+    while (d.depth > 1) d = d.parent;
+    return color(d.data.name);
+   };
+
+ const getTextTransform = 
+ (d: d3.HierarchyRectangularNode<Data>) => {
+    const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI;
+    const y = (d.y0 + d.y1) / 2;
+    return `rotate(${x - 90}) translate(${y},0) rotate(${x < + 180 ? 0 : 180})`;
+  };
+
+  const root = partition(data);

  return (
    <svg width={SIZE} height={SIZE} ref={svgRef}>
+      <g fillOpacity={0.6}>
+        {root
+          .descendants()
+          .filter((d) => d.depth)
+          .map((d, i) => (
+            <path 
+              key={`${d.data.name}-${i}`}
+              fill={getColor(d)}
+              d={arc(d)}
+             >
+              <text>
+                {d
+                  .ancestors()
+                  .map((d) => d.data.name)
+                  .reverse()
+                  .join("/")}
+                \n${format(d.value)}
+              </text>
+            </path>
+          ))}
+      </g>
+      <g
+        pointerEvents="none"
+        textAnchor="middle"
+        fontSize={10}
+        fontFamily="sans-serif"
+      >
+        {root
+          .descendants()
+          .filter((d) => d.depth && ((d.y0 + d.y1) / 2) * 
+          (d.x1 - d.x0) > 10)
+          .map((d, i) => (
+            <text
+              key={`${d.data.name}-${i}`}
+              transform={getTextTransform(d)}
+              dy="0.35em"
+            >
+              {d.data.name}
+            </text>
+          ))}
+      </g>
    </svg>
  );
};
```

Awesome, code looks much more readable! 

Last thing we are going to do it to pass viewBox value directly without using `attr()` function.

`getAutoBox` has to be run only one time and we are going to keep output of this function in the state. 

```diff

...

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);
+ const [viewBox, setViewBox] = React.useState("0,0,0,0");

...
- React.useEffect(() => {
-  const svg = d3.select(svgRef.current);
-  svg.attr("viewBox", getAutoBox);
- }, []);
+ React.useEffect(() => {
+   setViewBox(getAutoBox());
+ }, []);

...

  return (
    <svg 
     width={SIZE}
     height={SIZE}
+    viewBox={viewBox}
     ref={svgRef}
     >
...
};
```

Now we have chart fully managed by React with D3 calculations. 

<img width="100%" style="width:100%" src="https://media.giphy.com/media/3o7aCWJavAgtBzLWrS/giphy-downsized-large.gif"/>

Demo + full code: https://codesandbox.io/s/ioop1?file=/src/SunburstChart.tsx


----

I hope this article was helpful and gave you a basic idea about integrating D3 charts with React 😉

Make sure to follow me as I will post more content related to D3 and React.

Thanks for reading!