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.
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.
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.
React Dev Tool Profiler also told us that 196 re-renders happened during a short drag.
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.
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;