If you have attempted to integrate D3.js into a modern React application, you have likely encountered the "black box" problem. You initialize a ref, pass it to a useEffect hook, and write imperative D3 code to mutate the DOM manually.
It works, until it doesn't. React’s Strict Mode causes double-invocations that duplicate your axes. State updates desync because D3 is manipulating DOM nodes that React believes it controls. You end up writing complex cleanup logic just to prevent memory leaks.
There is an architectural friction here: D3 wants to be the UI library, but React demands to be the UI library.
Airbnb’s Visx library resolves this conflict not by replacing D3, but by unbundling it. It allows us to use D3 for the mathematics (scales, paths, generators) while letting React handle the rendering.
The Root Cause: Imperative vs. Declarative Conflict
To understand why Visx is necessary, we must analyze why standard D3 implementation fails in React.
React operates on a Declarative model. You describe the UI state, and React’s reconciliation algorithm (the Virtual DOM) determines the most efficient way to update the browser's DOM. React assumes it has exclusive ownership of the DOM nodes it manages.
D3 (Data-Driven Documents) operates on an Imperative model. You explicitly tell the browser to select a DOM node, append an SVG element, and set attributes.
When you mix them, you force React to yield control of a specific div or svg to D3. This creates several technical debts:
- Optimization Loss: React cannot optimize changes within the D3 block because it's opaque to the reconciler.
- Lifecycle Friction: You must manually bind D3's enter/update/exit cycles to React's hook dependencies.
- Server-Side Rendering (SSR) Failure: D3 typically requires the
windowobject and a real DOM, causing hydration errors in Next.js or Remix.
Visx solves this by stripping the DOM manipulation out of D3. We use React components to render <rect /> or <path /> elements, but we calculate their coordinates using D3’s math utilities.
The Fix: Building a Declarative Bar Chart
Let’s build a responsive, interactive bar chart. We will use visx for the primitives and TypeScript to ensure type safety.
1. Installation
Visx is modular. This keeps bundle sizes small, as you only install what you need.
npm install @visx/group @visx/shape @visx/scale @visx/axis @visx/grid @visx/responsive @visx/tooltip d3-array
2. The Data and Accessors
First, we define our data shape and helper functions. Accessors are critical in Visx to separate raw data from the logic that maps it to visual coordinates.
import React, { useMemo } from 'react';
import { Group } from '@visx/group';
import { Bar } from '@visx/shape';
import { scaleBand, scaleLinear } from '@visx/scale';
import { AxisBottom, AxisLeft } from '@visx/axis';
import { GridRows } from '@visx/grid';
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
import { localPoint } from '@visx/event';
// 1. Define Data Type
interface RevenueData {
month: string;
amount: number;
}
// 2. Mock Data
const data: RevenueData[] = [
{ month: 'Jan', amount: 1200 },
{ month: 'Feb', amount: 2100 },
{ month: 'Mar', amount: 800 },
{ month: 'Apr', amount: 1600 },
{ month: 'May', amount: 2500 },
{ month: 'Jun', amount: 1900 },
];
// 3. Accessors
const getMonth = (d: RevenueData) => d.month;
const getAmount = (d: RevenueData) => d.amount;
// Styles for the chart container
const background = '#eaedff';
const barColor = '#5c6ac4';
const hoverColor = '#202e78';
3. The Chart Component
This component demonstrates the "React for DOM, D3 for Math" philosophy. Notice there are no useEffect hooks manipulating the DOM. Everything is calculated during the render pass or memoized.
export type BarChartProps = {
width: number;
height: number;
};
export default function RevenueBarChart({ width, height }: BarChartProps) {
// Tooltip hooks provided by Visx
const {
tooltipOpen,
tooltipLeft,
tooltipTop,
tooltipData,
hideTooltip,
showTooltip
} = useTooltip<RevenueData>();
const { containerRef, TooltipInPortal } = useTooltipInPortal({
scroll: true,
});
// Margins create space for axes
const margin = { top: 40, right: 30, bottom: 50, left: 50 };
// Bounds (the area inside the axes)
const xMax = width - margin.left - margin.right;
const yMax = height - margin.top - margin.bottom;
// 4. Scales (Memoized for performance)
// Maps discrete categories (months) to pixel width
const xScale = useMemo(
() =>
scaleBand<string>({
range: [0, xMax],
round: true,
domain: data.map(getMonth),
padding: 0.4,
}),
[xMax]
);
// Maps values (amount) to pixel height
const yScale = useMemo(
() =>
scaleLinear<number>({
range: [yMax, 0], // SVG y-coordinates go top-to-bottom
round: true,
domain: [0, Math.max(...data.map(getAmount))],
}),
[yMax]
);
if (width < 10) return null;
return (
// containerRef is vital for correct tooltip positioning
<div style={{ position: 'relative' }} ref={containerRef}>
<svg width={width} height={height}>
<rect width={width} height={height} fill={background} rx={14} />
{/* Group handles the margin offset */}
<Group left={margin.left} top={margin.top}>
{/* Grid Lines */}
<GridRows
scale={yScale}
width={xMax}
height={yMax}
stroke="#e0e0e0"
/>
{/* Axes */}
<AxisBottom
top={yMax}
scale={xScale}
tickLabelProps={() => ({
fill: '#333',
fontSize: 11,
textAnchor: 'middle',
})}
/>
<AxisLeft
scale={yScale}
tickLabelProps={() => ({
fill: '#333',
fontSize: 11,
textAnchor: 'end',
dx: -5,
dy: 3,
})}
/>
{/* Bars */}
{data.map((d) => {
const month = getMonth(d);
const barWidth = xScale.bandwidth();
const barHeight = yMax - (yScale(getAmount(d)) ?? 0);
const barX = xScale(month);
const barY = yMax - barHeight;
return (
<Bar
key={`bar-${month}`}
x={barX}
y={barY}
width={barWidth}
height={barHeight}
fill={barColor}
// Interactive Events
onMouseLeave={() => hideTooltip()}
onMouseMove={(event) => {
const eventSvgCoords = localPoint(event);
showTooltip({
tooltipData: d,
tooltipTop: eventSvgCoords?.y,
tooltipLeft: eventSvgCoords?.x,
});
}}
style={{
transition: 'fill 0.2s ease',
cursor: 'pointer'
}}
// React-based hover effect via CSS classes or inline logic
onMouseEnter={(e) => {
e.currentTarget.style.fill = hoverColor;
}}
/>
);
})}
</Group>
</svg>
{/* Declarative Tooltip */}
{tooltipOpen && tooltipData && (
<TooltipInPortal
key={Math.random()} // Force re-render for position updates
top={tooltipTop}
left={tooltipLeft}
style={{ ...defaultStyles, backgroundColor: '#202e78', color: '#fff' }}
>
<div>
<strong>{getMonth(tooltipData)}</strong>
</div>
<div>${getAmount(tooltipData)}</div>
</TooltipInPortal>
)}
</div>
);
}
Deep Dive: Why This Architecture Works
The Group Component
In raw SVG, moving a collection of elements requires calculating transform="translate(x, y)" manually. Visx provides the <Group /> component, which acts as a wrapper. It applies the coordinate offset once, allowing all children (bars, axes, grids) to be positioned relative to (0,0) inside the chart area, not the SVG edge.
Scales are just Functions
The most common misconception is that D3 scales are DOM elements. They are not. A D3 scale is simply a pure mathematical function that accepts a data input (domain) and returns a pixel value (range). By wrapping these in useMemo, we ensure they are only recalculated when data or dimensions change, preserving the efficiency of React's render cycle.
React Event System
Notice the onMouseMove on the <Bar /> component. In a pure D3 approach, you would use d3.select(this).on('mouseover', ...). In Visx, we use standard React synthetic events. This gives us access to the React state and context without any bridging code.
Handling Responsive Sizing
A common pitfall with the code above is that width and height are hardcoded props. In a real dashboard, the chart must fill its parent container.
Visx provides a High Order Component (HOC) called ParentSize to handle this automatically. It uses a ResizeObserver under the hood to detect container changes and pass the new dimensions to your chart.
import { ParentSize } from '@visx/responsive';
import RevenueBarChart from './RevenueBarChart';
export default function DashboardCard() {
return (
<div style={{ height: '500px', width: '100%' }}>
<ParentSize>
{({ width, height }) => (
<RevenueBarChart width={width} height={height} />
)}
</ParentSize>
</div>
);
}
Common Pitfalls and Edge Cases
1. The "Invalid Value" Error
If your data contains null or undefined, D3 scales may return NaN, causing React to throw an error when rendering the <rect>. Always sanitize your data or use coalescing operators (e.g., yScale(value) ?? 0).
2. Animation Performance
While you can animate Visx components using CSS transitions (as shown in the style prop above), complex physics-based animations should use libraries like react-spring or framer-motion. Because Visx components are standard React components, they wrap easily:
// Example conceptual wrapper
<animated.rect
width={springProps.width}
height={springProps.height}
/* ... */
/>
3. Server-Side Rendering (SSR)
Visx is SSR-friendly, but ParentSize relies on the browser window. If you are using Next.js, ensure your responsive wrapper is client-side only, or provide fallback dimensions to prevent layout shift during hydration.
Conclusion
Visx bridges the gap between the low-level calculation power of D3 and the component-based architecture of React. By treating visualizations as standard React component trees, we gain access to the ecosystem's best features: efficient diffing, state management, and type safety.
Stop fighting the DOM with useEffect. Let D3 handle the math, and let React handle the view.