Skip to main content

React Hooks for D3.js

Advanced React hooks and patterns for building reusable D3.js visualizations with modern React practices.

Core D3 Hooks

useD3 - Base Hook for D3 Integration

import { useEffect, useRef } from 'react';
import * as d3 from 'd3';

export const useD3 = (renderChartFn, dependencies = []) => {
const ref = useRef();

useEffect(() => {
if (ref.current) {
renderChartFn(d3.select(ref.current));
}
return () => {
// Cleanup function to remove event listeners and transitions
if (ref.current) {
d3.select(ref.current).selectAll('*').interrupt();
}
};
}, dependencies);

return ref;
};

// Usage
const BarChart = ({ data }) => {
const ref = useD3(
svg => {
svg.selectAll('*').remove();

const width = 500;
const height = 300;
const margin = { top: 20, right: 20, bottom: 30, left: 40 };

const xScale = d3
.scaleBand()
.domain(data.map(d => d.name))
.range([margin.left, width - margin.right])
.padding(0.1);

const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.value)])
.range([height - margin.bottom, margin.top]);

svg.attr('width', width).attr('height', height);

svg
.selectAll('.bar')
.data(data)
.join('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.name))
.attr('y', d => yScale(d.value))
.attr('width', xScale.bandwidth())
.attr('height', d => height - margin.bottom - yScale(d.value))
.attr('fill', 'steelblue');

svg
.append('g')
.attr('transform', `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale));

svg
.append('g')
.attr('transform', `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale));
},
[data]
);

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

useScale - Reusable Scale Hook

import { useMemo } from 'react';
import * as d3 from 'd3';

export const useScale = (data, scaleType, accessor, range, options = {}) => {
return useMemo(() => {
if (!data || data.length === 0) return null;

const domain = options.domain || d3.extent(data, accessor);

switch (scaleType) {
case 'linear':
return d3.scaleLinear().domain(domain).range(range).nice(options.nice);

case 'band':
return d3
.scaleBand()
.domain(data.map(accessor))
.range(range)
.padding(options.padding || 0.1);

case 'time':
return d3.scaleTime().domain(domain).range(range).nice(d3.timeDay);

case 'ordinal':
return d3
.scaleOrdinal()
.domain(data.map(accessor))
.range(options.colors || d3.schemeCategory10);

case 'log':
return d3.scaleLog().domain(domain).range(range).nice();

default:
throw new Error(`Unsupported scale type: ${scaleType}`);
}
}, [data, scaleType, accessor, range, options]);
};

// Usage
const ScatterPlot = ({ data }) => {
const xScale = useScale(data, 'linear', d => d.x, [0, 400]);
const yScale = useScale(data, 'linear', d => d.y, [300, 0]);
const colorScale = useScale(data, 'ordinal', d => d.category);

const ref = useD3(
svg => {
if (!xScale || !yScale || !colorScale) return;

svg.selectAll('*').remove();
svg.attr('width', 500).attr('height', 400);

svg
.selectAll('.point')
.data(data)
.join('circle')
.attr('class', 'point')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 5)
.attr('fill', d => colorScale(d.category));
},
[data, xScale, yScale, colorScale]
);

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

useTransition - Animation Hook

import { useCallback, useRef } from 'react';
import * as d3 from 'd3';

export const useTransition = (duration = 1000, ease = d3.easeQuadInOut) => {
const transitionRef = useRef();

const createTransition = useCallback(
selection => {
const transition = selection.transition().duration(duration).ease(ease);

transitionRef.current = transition;
return transition;
},
[duration, ease]
);

const interruptTransition = useCallback(() => {
if (transitionRef.current) {
transitionRef.current.interrupt();
}
}, []);

return { createTransition, interruptTransition };
};

// Usage
const AnimatedBarChart = ({ data }) => {
const { createTransition } = useTransition(800, d3.easeBounce);

const ref = useD3(
svg => {
svg.selectAll('*').remove();

const width = 500;
const height = 300;
const margin = { top: 20, right: 20, bottom: 30, left: 40 };

const xScale = d3
.scaleBand()
.domain(data.map(d => d.name))
.range([margin.left, width - margin.right])
.padding(0.1);

const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.value)])
.range([height - margin.bottom, margin.top]);

svg.attr('width', width).attr('height', height);

const bars = svg
.selectAll('.bar')
.data(data)
.join('rect')
.attr('class', 'bar')
.attr('x', d => xScale(d.name))
.attr('y', height - margin.bottom)
.attr('width', xScale.bandwidth())
.attr('height', 0)
.attr('fill', 'steelblue');

// Animate bars growing up
createTransition(bars)
.attr('y', d => yScale(d.value))
.attr('height', d => height - margin.bottom - yScale(d.value));
},
[data, createTransition]
);

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

useTooltip - Interactive Tooltip Hook

import { useState, useCallback } from 'react';

export const useTooltip = () => {
const [tooltip, setTooltip] = useState({
visible: false,
x: 0,
y: 0,
content: null,
});

const showTooltip = useCallback((event, content) => {
setTooltip({
visible: true,
x: event.pageX + 10,
y: event.pageY - 10,
content,
});
}, []);

const hideTooltip = useCallback(() => {
setTooltip(prev => ({ ...prev, visible: false }));
}, []);

const TooltipComponent = ({ className = '', style = {} }) => {
if (!tooltip.visible) return null;

return (
<div
className={className}
style={{
position: 'absolute',
left: tooltip.x,
top: tooltip.y,
background: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '8px',
borderRadius: '4px',
fontSize: '12px',
pointerEvents: 'none',
zIndex: 1000,
...style,
}}
>
{tooltip.content}
</div>
);
};

return { tooltip, showTooltip, hideTooltip, TooltipComponent };
};

// Usage
const InteractiveChart = ({ data }) => {
const { showTooltip, hideTooltip, TooltipComponent } = useTooltip();

const ref = useD3(
svg => {
svg.selectAll('*').remove();
svg.attr('width', 500).attr('height', 300);

svg
.selectAll('.point')
.data(data)
.join('circle')
.attr('class', 'point')
.attr('cx', d => d.x * 10)
.attr('cy', d => d.y * 10)
.attr('r', 5)
.attr('fill', 'steelblue')
.style('cursor', 'pointer')
.on('mouseover', function (event, d) {
showTooltip(
event,
<div>
<div>
<strong>{d.name}</strong>
</div>
<div>X: {d.x}</div>
<div>Y: {d.y}</div>
<div>Value: {d.value}</div>
</div>
);
})
.on('mouseout', hideTooltip);
},
[data, showTooltip, hideTooltip]
);

return (
<>
<svg ref={ref}></svg>
<TooltipComponent />
</>
);
};

useZoom - Zoom and Pan Hook

import { useEffect, useRef, useCallback } from 'react';
import * as d3 from 'd3';

export const useZoom = (onZoom, options = {}) => {
const zoomRef = useRef();
const {
scaleExtent = [0.1, 10],
translateExtent = [
[-Infinity, -Infinity],
[Infinity, Infinity],
],
} = options;

const zoom = useRef(
d3
.zoom()
.scaleExtent(scaleExtent)
.translateExtent(translateExtent)
.on('zoom', event => {
onZoom(event.transform);
})
);

const resetZoom = useCallback(() => {
if (zoomRef.current) {
d3.select(zoomRef.current)
.transition()
.duration(750)
.call(zoom.current.transform, d3.zoomIdentity);
}
}, []);

const zoomToFit = useCallback((bounds, padding = 50) => {
if (!zoomRef.current) return;

const svg = d3.select(zoomRef.current);
const svgNode = svg.node();
const { width, height } = svgNode.getBoundingClientRect();

const [x0, y0, x1, y1] = bounds;
const scale = Math.min(
(width - padding * 2) / (x1 - x0),
(height - padding * 2) / (y1 - y0)
);

const transform = d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-(x0 + x1) / 2, -(y0 + y1) / 2);

svg.transition().duration(750).call(zoom.current.transform, transform);
}, []);

useEffect(() => {
if (zoomRef.current) {
d3.select(zoomRef.current).call(zoom.current);
}
}, []);

return { zoomRef, resetZoom, zoomToFit };
};

// Usage
const ZoomableChart = ({ data }) => {
const [transform, setTransform] = useState(d3.zoomIdentity);
const { zoomRef, resetZoom, zoomToFit } = useZoom(setTransform);

const ref = useD3(
svg => {
svg.selectAll('*').remove();
svg.attr('width', 600).attr('height', 400);

const g = svg.append('g').attr('transform', transform.toString());

g.selectAll('.point')
.data(data)
.join('circle')
.attr('class', 'point')
.attr('cx', d => d.x)
.attr('cy', d => d.y)
.attr('r', 3)
.attr('fill', 'steelblue');
},
[data, transform]
);

const handleZoomToFit = () => {
const bounds = [
d3.min(data, d => d.x),
d3.min(data, d => d.y),
d3.max(data, d => d.x),
d3.max(data, d => d.y),
];
zoomToFit(bounds);
};

return (
<div>
<div>
<button onClick={resetZoom}>Reset Zoom</button>
<button onClick={handleZoomToFit}>Zoom to Fit</button>
</div>
<svg
ref={node => {
ref.current = node;
zoomRef.current = node;
}}
></svg>
</div>
);
};

Specialized Chart Hooks

useLineChart - Line Chart Hook

import { useMemo } from 'react';
import * as d3 from 'd3';

export const useLineChart = (data, options = {}) => {
const {
width = 500,
height = 300,
margin = { top: 20, right: 20, bottom: 30, left: 40 },
curve = d3.curveLinear,
xAccessor = d => d.x,
yAccessor = d => d.y,
} = options;

const { scales, line } = useMemo(() => {
if (!data || data.length === 0) return { scales: null, line: null };

const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;

const xScale = d3
.scaleLinear()
.domain(d3.extent(data, xAccessor))
.range([0, innerWidth]);

const yScale = d3
.scaleLinear()
.domain(d3.extent(data, yAccessor))
.range([innerHeight, 0]);

const lineGenerator = d3
.line()
.x(d => xScale(xAccessor(d)))
.y(d => yScale(yAccessor(d)))
.curve(curve);

return {
scales: { xScale, yScale },
line: lineGenerator,
};
}, [data, width, height, margin, curve, xAccessor, yAccessor]);

const ref = useD3(
svg => {
if (!scales || !line) return;

svg.selectAll('*').remove();
svg.attr('width', width).attr('height', height);

const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);

// Draw line
g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', 'steelblue')
.attr('stroke-width', 2)
.attr('d', line);

// Add axes
g.append('g')
.attr(
'transform',
`translate(0,${height - margin.top - margin.bottom})`
)
.call(d3.axisBottom(scales.xScale));

g.append('g').call(d3.axisLeft(scales.yScale));
},
[data, scales, line, width, height, margin]
);

return { ref, scales };
};

// Usage
const LineChart = ({ data }) => {
const { ref } = useLineChart(data, {
width: 600,
height: 400,
curve: d3.curveCatmullRom,
xAccessor: d => d.date,
yAccessor: d => d.value,
});

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

useHeatmap - Heatmap Hook

export const useHeatmap = (data, options = {}) => {
const {
width = 500,
height = 300,
margin = { top: 20, right: 20, bottom: 30, left: 40 },
colorScheme = d3.interpolateViridis,
} = options;

const scales = useMemo(() => {
if (!data || data.length === 0) return null;

const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;

const xDomain = [...new Set(data.map(d => d.x))].sort();
const yDomain = [...new Set(data.map(d => d.y))].sort();

const xScale = d3
.scaleBand()
.domain(xDomain)
.range([0, innerWidth])
.padding(0.05);

const yScale = d3
.scaleBand()
.domain(yDomain)
.range([0, innerHeight])
.padding(0.05);

const colorScale = d3
.scaleSequential()
.domain(d3.extent(data, d => d.value))
.interpolator(colorScheme);

return { xScale, yScale, colorScale };
}, [data, width, height, margin, colorScheme]);

const ref = useD3(
svg => {
if (!scales) return;

svg.selectAll('*').remove();
svg.attr('width', width).attr('height', height);

const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);

g.selectAll('.cell')
.data(data)
.join('rect')
.attr('class', 'cell')
.attr('x', d => scales.xScale(d.x))
.attr('y', d => scales.yScale(d.y))
.attr('width', scales.xScale.bandwidth())
.attr('height', scales.yScale.bandwidth())
.attr('fill', d => scales.colorScale(d.value));

// Add axes
g.append('g')
.attr(
'transform',
`translate(0,${height - margin.top - margin.bottom})`
)
.call(d3.axisBottom(scales.xScale));

g.append('g').call(d3.axisLeft(scales.yScale));
},
[data, scales, width, height, margin]
);

return { ref, scales };
};

useBrush - Brush Selection Hook

export const useBrush = (onBrushEnd, options = {}) => {
const {
brushType = 'brushX', // 'brushX', 'brushY', or 'brush'
extent = [
[0, 0],
[400, 300],
],
} = options;

const brushRef = useRef();

const brush = useMemo(() => {
const brushFunction = d3[brushType]()
.extent(extent)
.on('end', event => {
if (event.selection) {
onBrushEnd(event.selection);
}
});

return brushFunction;
}, [brushType, extent, onBrushEnd]);

useEffect(() => {
if (brushRef.current) {
d3.select(brushRef.current).call(brush);
}
}, [brush]);

const clearBrush = useCallback(() => {
if (brushRef.current) {
d3.select(brushRef.current).call(brush.move, null);
}
}, [brush]);

return { brushRef, clearBrush };
};

// Usage
const BrushableChart = ({ data }) => {
const [selectedData, setSelectedData] = useState(data);

const handleBrushEnd = useCallback(
selection => {
if (!selection) {
setSelectedData(data);
return;
}

const [x0, x1] = selection;
const filtered = data.filter(d => d.x >= x0 && d.x <= x1);
setSelectedData(filtered);
},
[data]
);

const { brushRef, clearBrush } = useBrush(handleBrushEnd, {
extent: [
[0, 0],
[500, 50],
],
});

const chartRef = useD3(
svg => {
svg.selectAll('.chart-content').remove();

const g = svg
.append('g')
.attr('class', 'chart-content')
.attr('transform', 'translate(0, 60)');

g.selectAll('.bar')
.data(selectedData)
.join('rect')
.attr('class', 'bar')
.attr('x', d => d.x)
.attr('y', 0)
.attr('width', 10)
.attr('height', d => d.value)
.attr('fill', 'steelblue');
},
[selectedData]
);

return (
<div>
<button onClick={clearBrush}>Clear Selection</button>
<svg ref={chartRef} width={500} height={300}>
<g ref={brushRef}></g>
</svg>
</div>
);
};

These React hooks provide a clean, reusable way to integrate D3.js functionality into React components while maintaining React's declarative paradigm and leveraging modern hooks patterns for state management and side effects.