3 Comments

Using D3 with React and TypeScript

Typically, when working in React, it’s best to split UI elements into separate, reusable parts. This allows for more modular code and finer control over each element.

However, this goes against the way D3 operates, which is to subsequently call dot operators, building up elements and groups of elements.

So how can we utilize D3 in a meaningful way while simultaneously breaking up our elements into individual components? I’m going to show you by working through an example implementing a force graph.

Getting Started

For this example, I’ll be using TypeScript along with React and D3. If you have not used TypeScript with React before, I suggest using create-react-app. It’s an easy way to get a project up and running with no hassles and few changes. If you use this method, you may want to remove the logo.svg and registerServiceWorker.ts files since they aren’t needed here.

Make sure you include D3 and its types:

npm i -s d3
npm i -s @types/d3

You will also need some sample data for the graph, which you can find here. You’ll notice that instead of a .json file (like the force graph JS example), it uses a .ts file. This is because TypeScript has trouble importing .json files easily, though it can parse JSON strings. Using a .ts file and exporting it as an object is less hassle and works for what we need.

How to Start Using D3

One of the first things you might realize is that D3 uses a lot of .select or .selectAll operations. This poses a problem because it’s not usable until after elements have been rendered, so something like this doesn’t work:


export default class App extends React.Component<Props, {}> {
  render() {
    d3.select("svg")
    .append("circle")
    .attr("r", 5)
    .attr("cx", this.props.width / 2)
    .attr("cy", this.props.height / 2)
    .attr("fill", "red");

    return (
      <svg className="container" width={this.props.width} height={this.props.height}>
      </svg>
    );
  }
}

This example compiles and runs fine, but it won’t display anything other than the svg. To fix this, we need to use componentDidMount() and ref. ComponentDidMount() allows us to select elements and operate on them after they have been rendered. While it can be used without ref, ref allows us to reference this specific element within its own context without having to select by type, class, or ID. Also, ref should be typed.


export default class App extends React.Component<Props, {}> {
  ref: SVGSVGElement;

  componentDidMount() {
    d3.select(this.ref)
    .append("circle")
    .attr("r", 5)
    .attr("cx", this.props.width / 2)
    .attr("cy", this.props.height / 2)
    .attr("fill", "red");
  }

  render() {
    return (
      <svg className="container" ref={(ref: SVGSVGElement) => this.ref = ref}
        width={this.props.width} height={this.props.height}>
      </svg>
    );
  }
}

Create Some Types

It’s always a good idea to create types for the data you will be using and to keep them in another file to import where needed.


type d3Node = {
  id: string,
  group: number
};

type d3Link = {
  source: string,
  target: string,
  value: number
};

type Graph = {
  nodes: d3Node[],
  links: d3Link[]
};

One important thing to note is that when you set variables from a D3 operator, the variable type will need to be set to any. This is because D3 types can be a bit lengthy to use unless you extend, and in some cases, they will cause a possible variable undefined error.

Splitting Up the Elements

Here is what our code would look like if we kept it all in a single element:


export default class App extends React.Component<Props, {}> {
  ref: SVGSVGElement;

  componentDidMount() {
    const context: any = d3.select(this.ref);
    const color = d3.scaleOrdinal(d3.schemeCategory20);

    const simulation: any = d3.forceSimulation()
      .force("link", d3.forceLink().id(function(d: d3Node) {
        return d.id;
      }))
      .force("charge", d3.forceManyBody())
      .force("center", d3.forceCenter(this.props.width / 2, this.props.height / 2));

    var link = context.append("g")
      .attr("class", "links")
      .selectAll("line")
      .data(this.props.graph.links)
      .enter().append("line")
      .attr("stroke-width", function(d: d3Link) {
        return Math.sqrt(d.value);
      });

    const node = context.append("g")
      .attr("class", "nodes")
      .selectAll("circle")
      .data(this.props.graph.nodes)
      .enter().append("circle")
      .attr("r", 5)
      .attr("fill", function(d: d3Node) {
        return color(d.group.toString());
      })
      .call(d3.drag()
          .on("start", dragstarted)
          .on("drag", dragged)
          .on("end", dragended));

      node.append("title")
        .text(function(d: d3Node) {
          return d.id;
        });

    simulation.nodes(this.props.graph.nodes).on("tick", ticked);
    simulation.force("link").links(this.props.graph.links);

    function dragstarted(d: any) {
      if (!d3.event.active) {
        simulation.alphaTarget(0.3).restart();
      }
      d.fx = d.x;
      d.fy = d.y;
    }

    function dragged(d: any) {
      d.fx = d3.event.x;
      d.fy = d3.event.y;
    }

    function dragended(d: any) {
      if (!d3.event.active) {
        simulation.alphaTarget(0);
      }
      d.fx = null;
      d.fy = null;
    }
    
    function ticked() {
      link
        .attr("x1", function(d: any) {
          return d.source.x;
        })
        .attr("y1", function(d: any) {
          return d.source.y;
        })
        .attr("x2", function(d: any) {
          return d.target.x;
        })
        .attr("y2", function(d: any) {
          return d.target.y;
        });

      node
        .attr("cx", function(d: any) {
          return d.x;
        })
        .attr("cy", function(d: any) {
          return d.y;
        });
    }
  }

  render() {
    const { width, height } = this.props;

    return (
      <svg className="container" ref={(ref: SVGSVGElement) => this.ref = ref}
        width={width} height={height}>
      </svg>
    );
  }
}

Even though this code would work as intended, it’s pretty harsh to look at. D3 wants to have large blocks of dot operators to create new elements and to add events to them. However, this goes against how React should be used, which is to have separate components.

So let’s split them apart. When we do, we should keep a few things in mind.

Keep in Mind

  • Events in D3 will be overridden if the same event is specified more than once on the same set of data. This means the ticked function must remain in the top-level component.
  • Since the drag events use the simulation, we will need to pass it down to those components. However, because it’s also used for the ticked function, we need to create it before the main component renders while maintaining a reference to it. We can do this in the constructor.

Knowing this, we can split up our code into components like so:


export default class App extends React.Component<Props, {}> {
  ref: HTMLDivElement;
  simulation: any;

  constructor(props: Props) {
    super(props);
    this.simulation = d3.forceSimulation()
      .force("link", d3.forceLink().id(function(d: d3Node) {
        return d.id;
      }))
      .force("charge", d3.forceManyBody().strength(-100))
      .force("center", d3.forceCenter(this.props.width / 2, this.props.height / 2))
      .nodes(this.props.graph.nodes);

    this.simulation.force("link").links(this.props.graph.links);
  }

  componentDidMount() {
    const node = d3.select(".nodes").selectAll("circle");
    const link = d3.select(".links").selectAll("line");

    this.simulation.nodes(this.props.graph.nodes).on("tick", ticked);

    function ticked() {
      link
        .attr("x1", function(d: any) {
          return d.source.x;
        })
        .attr("y1", function(d: any) {
          return d.source.y;
        })
        .attr("x2", function(d: any) {
          return d.target.x;
        })
        .attr("y2", function(d: any) {
          return d.target.y;
        });

      node
        .attr("cx", function(d: any) {
          return d.x;
        })
        .attr("cy", function(d: any) {
          return d.y;
        });
    }
  }

  render() {
    const { width, height, graph } = this.props;

    return (
      <svg className="container" width={width} height={height}>
        <Links links={graph.links}/>
        <Nodes nodes={graph.nodes} simulation={this.simulation}/>
      </svg>
    );
  }
}

class Links extends React.Component<{links: d3Link[]}, {}> {
  ref: SVGGElement;

  componentDidMount() {
    const context: any = d3.select(this.ref);
    context
      .selectAll("line")
      .data(this.props.links)
      .enter().append("line")
      .attr("stroke-width", function(d: d3Link) {
        return Math.sqrt(d.value);
      });
  }

  render() {
    return <g className="links" ref={(ref: SVGGElement) => this.ref = ref}/>;
  }
}

class Nodes extends React.Component<{nodes: d3Node[], simulation: any}, {}> {
  ref: SVGGElement;

  componentDidMount() {
    const context: any = d3.select(this.ref);
    const simulation = this.props.simulation;
    const color = d3.scaleOrdinal(d3.schemeCategory20);
    
    context.selectAll("circle")
      .data(this.props.nodes)
      .enter().append("circle")
      .attr("r", 5)
      .attr("fill", function(d: d3Node) {
        return color(d.group.toString());
      })
      .call(d3.drag()
          .on("start", onDragStart)
          .on("drag", onDrag)
          .on("end", onDragEnd))
      .append("title")
        .text(function(d: d3Node) {
          return d.id;
        });

    function onDragStart(d: any) {
      if (!d3.event.active) {
        simulation.alphaTarget(0.3).restart();
      }
      d.fx = d.x;
      d.fy = d.y;
    }

    function onDrag(d: any) {
      d.fx = d3.event.x;
      d.fy = d3.event.y;
    }

    function onDragEnd(d: any) {
      if (!d3.event.active) {
        simulation.alphaTarget(0);
      }
      d.fx = null;
      d.fy = null;
    }
  }

  render() {
    return <g className="nodes" ref={(ref: SVGGElement) => this.ref = ref}/>;
  }
}

Looking Good, But Could We Improve It?

Though we have more components and the code looks cleaner and more organized, we still have the large blocks of dot operators to generate elements. This isn’t ideal, as it’s not as modular as we’d like it to be. Well, the good news is, we can split those up into components, too. Here’s an example:


class Link extends React.Component<{link: d3Link}, {}> {
  ref: SVGLineElement;

  componentDidMount() {
    d3.select(this.ref).data([this.props.link]);
  }

  render() {
    return <line className="link" ref={(ref: SVGLineElement) => this.ref = ref}
      strokeWidth={Math.sqrt(this.props.link.value)}/>;
  }
}

export default class Links extends React.Component<{links: d3Link[]}, {}> {
  render() {
    const links = this.props.links.map((link: d3Link, index: number) => {
      return <Link key={index} link={link}/>;
    });

    return (
      <g className="links">
        {links}
      </g>
    );
  }
}

Now, the reference is only in the individual link element to attach the data. The group component could be removed and the element moved into the top-level component depending on your personal preference and what you might be doing. For the nodes, I did leave the drag event call in the group instead of the node component as the simulation would have needed to be passed again. This is a balance to consider between React and D3.

Wrapping Up

These are the basics needed to use D3 with React and TypeScript. The sample code can be found here. Credit to Mike Bostock for the force graph code in JS.