Building and Optimizing an SVG Widget with Dragging and Clicking Support Using D3.js and React

April 17, 2025

This blog demonstrates how to implement an SVG-based widget with drag-and-drop and click functionality using D3.js in React, along with profiling and optimizing its performance.

Basic Implementation

Starting with a React & TypeScript application created using Vite, with D3.js and its types installed, we begin by adding an SVG tag to the returned component as the widget container. For simplicity, we apply inline styles to the SVG, setting its width and height to 100vw and 100vh, allowing it to fill the entire browser window.

import React from "react";
import * as d3 from "d3";

function App() {
	return <svg style={{ width: "100vw", height: "100vh" }}></svg>;
}

export default App;

Next, we define a corresponding type for the nodes to be rendered and interacted within the SVG. Each node is called a Datum in D3.js, and it’s recommended to use the x and y properties for node coordinates, as this satisfies the generic constraints of d3.SimulationNodeDatum, making it easier to integrate with more advanced use cases like simulation.

interface Node {
	id: number;
	x: number;
	y: number;
	highlighted: boolean;
}

Then, we randomly generate some nodes for the widget, store them in a React state, and render them within the svg tag with simple styling.

import React from "react";
import * as d3 from "d3";

const nodeRadius = 20;
const nodeBorderWidth = 4;

interface Node {
	id: number;
	x: number;
	y: number;
	highlighted: boolean;
}

interface CircleNodeProps {
	node: Node;
}

function CircleNode({ node }: CircleNodeProps) {
	return (
		<circle
			cx={node.x}
			cy={node.y}
			r={nodeRadius}
			stroke={node.highlighted ? "#f97316" : "#ec4899"}
			strokeWidth={nodeBorderWidth}
			fill="transparent"
			style={{ cursor: "pointer" }}
		/>
	);
}

function App() {
	const [nodes, setNodes] = React.useState<Node[]>(
		Array.from({ length: 10 }, (_, i) => ({
			id: i,
			x: Math.random() * window.innerWidth,
			y: Math.random() * window.innerHeight,
			highlighted: false,
		}))
	);

	return (
		<svg style={{ width: "100vw", height: "100vh" }}>
			{nodes.map((node) => (
				<CircleNode key={node.id} node={node} />
			))}
		</svg>
	);
}

export default App;

We can now see the nodes are being rendered on the screen.

nodes

To enable click highlighting and dragging for a circle node, we define two handler functions in the parent component and pass them to CircleNode.

In CircleNode, we select the current node with d3 by referencing the circle element, bind the corresponding node data to it, and add drag and click behaviors using an effect after the circle node mounts.

import React from 'react';
import * as d3 from 'd3';

const nodeRadius = 20;
const nodeBorderWidth = 4;

interface Node {
  id: number;
  x: number;
  y: number;
  highlighted: boolean;
}

interface CircleNodeProps {
  node: Node;
  syncNodeState: (id: number, x: number, y: number) => void;
  handleNodeClick: (id: number) => void;
}

function CircleNode({ node, syncNodeState, handleNodeClick }: CircleNodeProps) {
  const circleRef = React.useRef<SVGCircleElement | null>(null);

  React.useEffect(() => {
    if (!circleRef.current) return;

    // bind node data to svg element
    const circle = d3
      .select<SVGCircleElement, Node>(circleRef.current)
      .data([node]);

    // add drag behavior
    circle.call(
      d3
        .drag<SVGCircleElement, Node>()
        .on(
          'drag',
          (event: d3.D3DragEvent<SVGCircleElement, Node, Node>, d) => {
            syncNodeState(d.id, event.x, event.y);
          }
        )
    );

    // add click behavior
    circle.on('click', () => {
      handleNodeClick(node.id);
    });
  }, [node, syncNodeState, handleNodeClick]);

  return (
    <circle
      ref={circleRef}
      cx={node.x}
      cy={node.y}
      r={nodeRadius}
      stroke={node.highlighted ? '#f97316' : '#ec4899'}
      strokeWidth={nodeBorderWidth}
      fill="transparent"
      style={{ cursor: 'pointer' }}
    />
  );
}

function App() {
  const [nodes, setNodes] = React.useState<Node[]>(
    Array.from({ length: 10 }, (_, i) => ({
      id: i,
      x: Math.random() * window.innerWidth,
      y: Math.random() * window.innerHeight,
      highlighted: false,
    }))
  );

  // state syncing handler for drag
  const syncNodeState = (id: number, x: number, y: number) => {
    setNodes((prevNodes) =>
      prevNodes.map((node) => (node.id === id ? { ...node, x, y } : node))
    );
  };

  // click handler
  const handleNodeClick = (id: number) => {
    setNodes((prevNodes) =>
      prevNodes.map((node) =>
        node.id === id ? { ...node, highlighted: !node.highlighted } : node
      )
    );
  };

  return (
    <svg style={{ width: '100vw', height: '100vh' }}>
      {nodes.map((node) => (
        <CircleNode
          syncNodeState={syncNodeState}
          handleNodeClick={handleNodeClick}
          key={node.id}
          node={node}
        />
      ))}
    </svg>
  );
}

export default App;

We are now able to click a node and drag it.

demo

Profiling and Optimizing

You may notice that in the current implementation, the effect reruns every time the node’s coordinates change while dragging, causing unnecessary re-renders.

React.useEffect(() => {
	if (!circleRef.current) return;

	// bind node data to svg element
	const circle = d3
		.select<SVGCircleElement, Node>(circleRef.current)
		.data([node]);

	// add drag behavior
	circle.call(
		d3
			.drag<SVGCircleElement, Node>()
			.on(
				"drag",
				(event: d3.D3DragEvent<SVGCircleElement, Node, Node>, d) => {
					syncNodeState(d.id, event.x, event.y);
				}
			)
	);

	// add click behavior
	circle.on("click", () => {
		handleNodeClick(node.id);
	});
}, [node, syncNodeState, handleNodeClick]);

We can visualize the re-renders with React Scan.

nonoptimized_gif

React Dev Tool Profiler also told us that 196 re-renders happened during a short drag.

nonoptimized_png

These mid-way re-renders are unnecessary since they’re just "animations". If the CircleNode element subscribes to many external data sources, these re-renders can cause performance issues.

We only need to know the final state of the drag and sync it with the global state.

To optimize, we can directly modify the circle node’s HTML transform style by accessing its reference and only sync the state once when the drag ends, which adds just one more re-render.

One more challenge with this approach is that D3’s drag event intercepts click events, so we need to check whether the current event is a drag or highlighting event in the drag-end handler.

circle.call(
	d3
		.drag<SVGCircleElement, Node>()
		.on("drag", (event) => {
			if (circleRef.current) {
				circleRef.current.style.transform = `translate(${
					event.x - node.x
				}px, ${event.y - node.y}px)`;
			}
		})
		.on("end", (event: d3.D3DragEvent<SVGCircleElement, Node, Node>, d) => {
			if (d.highlighted) return;

			// update the state only once when the drag ends
			syncNodeState(d.id, event.x, event.y);

			// reset the transform style
			if (circleRef.current) {
				circleRef.current.style.transform = "";
			}
		})
);

After the optimization, we can see that the component only renders twice: once when it mounts and again when the drag ends, syncing the state.

optimized_gif

optimized_png

The final code is as follows and can also be found on StackBlitz.

import React from 'react';
import * as d3 from 'd3';

const nodeRadius = 20;
const nodeBorderWidth = 4;

interface Node {
  id: number;
  x: number;
  y: number;
  highlighted: boolean;
}

interface CircleNodeProps {
  node: Node;
  syncNodeState: (id: number, x: number, y: number) => void;
  handleNodeClick: (id: number) => void;
}

function CircleNode({ node, syncNodeState, handleNodeClick }: CircleNodeProps) {
  const circleRef = React.useRef<SVGCircleElement | null>(null);

  React.useEffect(() => {
    if (!circleRef.current) return;

    // bind node data to svg element
    const circle = d3
      .select<SVGCircleElement, Node>(circleRef.current)
      .data([node]);

    // add drag behavior
    circle.call(
      d3
        .drag<SVGCircleElement, Node>()
        .on('drag', (event) => {
          if (circleRef.current) {
            circleRef.current.style.transform = `translate(${
              event.x - node.x
            }px, ${event.y - node.y}px)`;
          }
        })
        .on('end', (event: d3.D3DragEvent<SVGCircleElement, Node, Node>, d) => {
          if (d.highlighted) return;

          // update the state only once when the drag ends
          syncNodeState(d.id, event.x, event.y);

          // reset the transform style
          if (circleRef.current) {
            circleRef.current.style.transform = '';
          }
        })
    );

    // add click behavior
    circle.on('click', () => {
      handleNodeClick(node.id);
    });
  }, [node, syncNodeState, handleNodeClick]);

  return (
    <circle
      ref={circleRef}
      cx={node.x}
      cy={node.y}
      r={nodeRadius}
      stroke={node.highlighted ? '#f97316' : '#ec4899'}
      strokeWidth={nodeBorderWidth}
      fill="transparent"
      style={{ cursor: 'pointer' }}
    />
  );
}

function App() {
  const [nodes, setNodes] = React.useState<Node[]>(
    Array.from({ length: 10 }, (_, i) => ({
      id: i,
      x: Math.random() * window.innerWidth,
      y: Math.random() * window.innerHeight,
      highlighted: false,
    }))
  );

  const syncNodeState = (id: number, x: number, y: number) => {
    setNodes((prevNodes) =>
      prevNodes.map((node) => (node.id === id ? { ...node, x, y } : node))
    );
  };

  const handleNodeClick = (id: number) => {
    setNodes((prevNodes) =>
      prevNodes.map((node) =>
        node.id === id ? { ...node, highlighted: !node.highlighted } : node
      )
    );
  };

  return (
    <svg style={{ width: '100vw', height: '100vh' }}>
      {nodes.map((node) => (
        <CircleNode
          key={node.id}
          node={node}
          syncNodeState={syncNodeState}
          handleNodeClick={handleNodeClick}
        />
      ))}
    </svg>
  );
}

export default App;