1. The DOM-Ownership Conflict
D3, used the way most tutorials teach it, calls d3.select(...) on a real
element and then creates, updates and removes child nodes directly. Angular's template
engine assumes it alone controls what's inside a component's view. Point both at the same
subtree without a plan, and you get nodes that flicker, duplicate, or vanish the moment
Angular re-renders something D3 already changed underneath it.
The fix isn't a special "D3 for Angular" library — it's picking, deliberately, which one of them owns which part of the DOM, and never letting them both write to the same element at once.
2. Two Integration Patterns
- Pattern A — D3 for math only. Use D3's scales, shape generators and layout algorithms purely as functions that return numbers and path strings; Angular's template renders the actual SVG elements via
@for. D3 never touches the DOM directly. - Pattern B — D3 owns a subtree. Give D3 a single container element (often via a directive) and let it fully manage everything inside — enter/update/exit, transitions, all of it. Angular renders the container once and otherwise leaves that subtree alone.
Pattern A is simpler, more debuggable, and fits Angular's mental model — prefer it by default. Pattern B earns its complexity for charts that genuinely need D3's enter/update/ exit transition machinery (smoothly animating bars as they add and remove, force-directed layouts) where re-deriving that in template bindings would fight D3 rather than use it.
3. Scales, Shapes & an Angular-Rendered Chart
Pattern A in practice: D3 computes, the template renders.
import { Component, input, computed } from '@angular/core';
import { scaleLinear, scaleBand } from 'd3-scale';
@Component({
selector: 'app-bar-chart',
templateUrl: './bar-chart.html',
})
export class BarChart {
data = input.required<{ label: string; value: number }[]>();
width = input(480);
height = input(240);
private margin = { top: 16, right: 16, bottom: 32, left: 40 };
xScale = computed(() =>
scaleBand()
.domain(this.data().map((d) => d.label))
.range([this.margin.left, this.width() - this.margin.right])
.padding(0.2),
);
yScale = computed(() =>
scaleLinear()
.domain([0, Math.max(...this.data().map((d) => d.value), 1)])
.range([this.height() - this.margin.bottom, this.margin.top]),
);
bars = computed(() =>
this.data().map((d) => ({
label: d.label,
x: this.xScale()(d.label)!,
y: this.yScale()(d.value),
width: this.xScale().bandwidth(),
height: this.height() - this.margin.bottom - this.yScale()(d.value),
})),
);
}
<svg [attr.width]="width()" [attr.height]="height()">
@for (bar of bars(); track bar.label) {
<rect
[attr.x]="bar.x"
[attr.y]="bar.y"
[attr.width]="bar.width"
[attr.height]="bar.height"
class="bar"
/>
}
</svg>
Because bars is a computed() (Week 5), the chart redraws
automatically whenever data() or width() changes — no manual
redraw call, and D3's scaleLinear/scaleBand are used purely as
math, never touching an actual DOM node.
4. Letting D3 Own a Subtree (Pattern B)
For a chart that genuinely needs D3's transition engine, wrap the imperative code in a directive so it's isolated from the rest of the component and cleans up after itself.
import { Directive, input, effect, ElementRef, inject, OnDestroy } from '@angular/core';
import { select } from 'd3-selection';
import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';
@Directive({ selector: '[appForceGraph]' })
export class ForceGraphDirective implements OnDestroy {
private host = inject(ElementRef<SVGSVGElement>);
private simulation = forceSimulation();
nodes = input.required<GraphNode[]>({ alias: 'appForceGraph' });
links = input.required<GraphLink[]>();
constructor() {
effect(() => {
const svg = select(this.host.nativeElement);
svg.selectAll('*').remove(); // this subtree is D3's alone -- clear and rebuild
this.simulation
.nodes(this.nodes())
.force('link', forceLink(this.links()).id((d: any) => d.id))
.force('charge', forceManyBody().strength(-120))
.force('center', forceCenter(240, 160));
const node = svg.selectAll('circle').data(this.nodes()).join('circle').attr('r', 6);
this.simulation.on('tick', () => {
node.attr('cx', (d: any) => d.x).attr('cy', (d: any) => d.y);
});
});
}
ngOnDestroy() {
this.simulation.stop();
}
}
The rule that makes this safe: nothing else in the component's template ever renders
inside this <svg>. Angular owns the element itself (via the directive
host); everything inside it belongs to D3 alone. ngOnDestroy stopping the
simulation matters for the same reason Week 15's subscription cleanup did — a running
force simulation on a destroyed component leaks CPU work indefinitely.
5. Responsive Charts with ResizeObserver
A chart with a hardcoded pixel width breaks the moment it's dropped into a flexible
layout (Week 7). ResizeObserver, wired into a signal, fixes that for either
pattern above.
import { Directive, ElementRef, inject, signal, afterNextRender, OnDestroy } from '@angular/core';
@Directive({ selector: '[appChartSize]', exportAs: 'chartSize' })
export class ChartSizeDirective implements OnDestroy {
private host = inject(ElementRef<HTMLElement>);
private observer?: ResizeObserver;
width = signal(480);
height = signal(240);
constructor() {
afterNextRender(() => {
this.observer = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
this.width.set(Math.round(width));
this.height.set(Math.round(height));
});
this.observer.observe(this.host.nativeElement);
});
}
ngOnDestroy() {
this.observer?.disconnect();
}
}
afterNextRender (Week 21's SSR-safety pattern, reused here) defers creating
the observer until the element genuinely exists in the browser DOM — important because
ResizeObserver doesn't exist during server-side rendering at all. Feed
width()/height() from this directive straight into Section 3's
BarChart inputs and the chart resizes itself with no manual event listeners
anywhere.
6. Keeping D3 Out of Angular's Change-Detection Path
A force simulation (Section 4) can tick dozens of times per second — running that inside
Angular's zone means every tick potentially triggers a full change-detection pass across
the whole app, for updates that only affect a handful of cx/cy
attributes D3 is already setting directly.
private zone = inject(NgZone);
constructor() {
effect(() => {
this.zone.runOutsideAngular(() => {
// the simulation.on('tick', ...) callback from Section 4 goes here --
// it only ever writes D3-managed attributes, so Angular never needs
// to know it happened
});
});
}
This is the same instinct as Week 20's zoneless/OnPush guidance, applied narrowly: high-frequency imperative DOM work that Angular's template isn't involved in shouldn't pay change-detection cost it doesn't need.
7. Hands-on Exercise
Build a responsive dashboard chart component
A reusable chart that takes data as a signal input and stays correctly sized.
Requirements:
- Build the
BarChartcomponent from Section 3 (Pattern A), taking adatasignal input of at least 5 categories. - Wire it to the
ChartSizeDirectivefrom Section 5 so the chart'swidth/heightinputs come from the container's actual measured size, not a hardcoded value. - Add a simple x-axis and y-axis using D3's
axisBottom/axisLeftgenerators, rendered via Pattern A (compute tick positions/labels with D3, render them with@forin the template — do not call D3's own.call(axis)DOM-mutating method here). - Confirm resizing the browser window redraws the chart at the new size, and that swapping the input data redraws it with the new values — both without a manual redraw call anywhere in your code.
- Add a smooth transition when values change (bars growing/shrinking) using CSS transitions on the SVG attributes rather than D3's transition API — a good demonstration that Pattern A doesn't lose visual polish by avoiding Pattern B.
CSS transitions on SVG attributes need the attribute to change via a re-render, not a direct DOM mutation — since Pattern A already updates via Angular's template bindings, adding transition: y 300ms, height 300ms; to the .bar class in Section 3 should "just work" without any D3 transition code at all.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What actually breaks when D3 and Angular both write to the same DOM subtree without a clear boundary?
What actually breaks when D3 and Angular both write to the same DOM subtree without a clear boundary?
Both assume exclusive control over what's inside that subtree. Angular's next re-render can overwrite or remove nodes D3 just created (or vice versa), producing flicker, duplicated elements, or elements silently vanishing — because neither system knows about the other's changes.
Q2
In Pattern A, what is D3 actually being used for, and what renders the SVG elements?
In Pattern A, what is D3 actually being used for, and what renders the SVG elements?
D3 is used purely as a math library — scales and shape generators that turn data into numbers and coordinates. Angular's own template, via @for and attribute bindings, creates and updates the actual DOM elements; D3 never calls .append() or .attr() on a real node.
Q3
Why does the responsive chart directive use afterNextRender before creating a ResizeObserver?
Why does the responsive chart directive use afterNextRender before creating a ResizeObserver?
ResizeObserver is a browser API that doesn't exist during server-side rendering (Week 21). afterNextRender guarantees the callback only runs in the browser, after the element genuinely exists in the DOM — creating it any earlier would either error during SSR or observe an element that isn't there yet.
Q4
Why run a D3 force simulation's tick callback with NgZone.runOutsideAngular()?
Why run a D3 force simulation's tick callback with NgZone.runOutsideAngular()?
A force simulation can tick many times per second, and each tick only updates attributes D3 is already writing directly — nothing Angular's template needs to know about. Running it inside the zone would trigger a full change-detection pass on every tick for no benefit; running it outside avoids that cost entirely, the same instinct as Week 20's performance guidance.