Usar D3 solo para la matemática
El enfoque que usa la mayoría en 2026: importar los módulos puros, calcular la geometría y dejar que tu framework o tu lienzo escriban el resultado, sin que dos dueños se peleen por el DOM.
El patrón dominante hoy no es el que hizo famoso a D3. Es este: importas tres módulos, calculas números, y el DOM lo escribe quien ya era su dueño, sea React, Svelte, Vue o tu propio código sobre un lienzo. No es una moda ni una traición: es la consecuencia directa de que veinticuatro de los treinta módulos de D3 nunca han tocado el DOM.
- Separar el cálculo con D3 del render con tu framework o tu lienzo.
- Identificar los módulos que siguen mereciendo la pena aunque toquen el DOM.
- Importar de forma que el empaquetador pueda eliminar lo que no usas.
- Elegir entre no usar nada, usar D3, usar una capa declarativa o una biblioteca de gráficos.
La frontera
La regla cabe en una frase: D3 calcula, tú dibujas.
import { scaleLinear, scaleBand } from 'd3-scale';
import { line, curveMonotoneX } from 'd3-shape';
import { extent, max } from 'd3-array';
import { format } from 'd3-format';
export function calcular(datos, { ancho, alto }) {
const x = scaleLinear()
.domain(extent(datos, d => d.fecha))
.range([0, ancho]);
const y = scaleLinear()
.domain([0, max(datos, d => d.valor)])
.nice()
.range([alto, 0]);
const fmt = format(',.0f');
return {
d: line().x(d => x(d.fecha)).y(d => y(d.valor)).curve(curveMonotoneX)(datos),
marcasY: y.ticks(5).map(v => ({ y: y(v), texto: fmt(v) })),
marcasX: x.ticks(6).map(v => ({ x: x(v), texto: String(v) })),
ancho, alto,
};
}
Esa función es exactamente la capa de especificación de el patrón de componente de gráfico: pura, testable sin navegador, sin una sola referencia al DOM. Lo único que ha cambiado es que las escalas y el generador vienen de D3 en lugar de estar escritos a mano.
Y el render, en el framework que sea, es un mapeo directo de esa estructura a marcado. En una plantilla declarativa se escribe como se escribe cualquier lista. En imperativo, con el ayudante de creación de elementos del nivel 26. En un lienzo, cambiando el generador a la variante con contexto.
Las tres ventajas de la frontera, en orden de importancia:
Un solo dueño del DOM. Es la razón principal y se desarrolla en el callout.
La especificación se puede testar. Comprobar que la primera marca del eje vale 0 y la última 250 es una prueba unitaria de dos líneas que corre en milisegundos. Comprobar lo mismo a través de d3-selection requiere un DOM simulado y una consulta por selector.
El render se puede cambiar sin tocar la geometría. La misma calcular alimenta un SVG en el servidor y un lienzo en el cliente. Es la puerta al enfoque híbrido.
Qué merece la pena de la parte que sí toca el DOM
Descartar d3-selection no implica descartar los seis módulos que tocan el DOM. Tres de ellos resuelven problemas que reimplementar cuesta semanas.
d3-zoom. Encapsula el desplazamiento y el zoom con rueda, con pellizco táctil, con doble clic y con arrastre, normalizando las diferencias entre navegadores en el evento de rueda, que son considerables. Mantiene una transformación con k, x e y, y expone métodos para reescalar una escala existente. Se usa así, sin que D3 dibuje nada:
import { zoom, zoomIdentity } from 'd3-zoom';
import { select } from 'd3-selection';
let transformacion = zoomIdentity;
select(lienzo).call(
zoom()
.scaleExtent([1, 40])
.on('zoom', (evento) => {
transformacion = evento.transform;
dibujar(); // tu funcion, tu lienzo
})
);
function dibujar() {
const xz = transformacion.rescaleX(x); // escala reescalada por el zoom
// ...dibuja con xz en lugar de x
}
d3-selection aparece ahí solo como adaptador para enganchar el comportamiento a un elemento. Nada más.
d3-drag hace lo mismo con el arrastre, unificando ratón y táctil, y resolviendo los casos raros (el puntero que sale de la ventana, el botón secundario, el arrastre que empieza sobre un hijo).
d3-brush implementa la selección rectangular con sus asas redimensionables. Es mucho más trabajo del que parece.
d3-axis, en cambio, no compensa: genera marcado SVG con sus propias convenciones de clases y hay que pelearse con él para estilarlo. Escribir el eje a mano, como en construir un eje a mano, da más control por menos código.
Lo que llega al paquete final
Los módulos de D3 se publican como módulos ES, así que un empaquetador moderno puede eliminar lo que no uses. Puede, no siempre lo hace.
// Lo que quieres escribir
import { scaleLinear } from 'd3-scale';
import { line } from 'd3-shape';
// Aceptable con un empaquetador que sacuda bien el arbol
import { scaleLinear, line } from 'd3';
// Lo que destruye cualquier posibilidad de eliminacion
import * as d3 from 'd3';
const escala = d3[`scale${tipo}`](); // acceso dinamico
La última línea es la que hay que evitar a toda costa. Un acceso por propiedad calculada obliga al empaquetador a conservar el objeto completo, porque no puede saber qué propiedades se van a leer. Y aparece con más frecuencia de lo que parece: en cualquier función que elija el tipo de escala a partir de una configuración.
La forma correcta de resolver ese caso es un mapa explícito:
import { scaleLinear, scaleLog, scaleTime, scaleBand } from 'd3-scale';
const ESCALAS = { lineal: scaleLinear, log: scaleLog, tiempo: scaleTime, banda: scaleBand };
const escala = (ESCALAS[tipo] ?? scaleLinear)();
Ahora las cuatro importaciones son estáticas, el empaquetador ve exactamente qué se usa, y el resto de d3-scale no viaja.
Dos avisos concretos sobre el peso: d3-geo y d3-scale-chromatic son grandes en relación con lo que suele usarse de ellos. De d3-scale-chromatic se importa casi siempre una sola paleta, así que importar la constante concreta en lugar del módulo entero ahorra bastante. Y en cualquier caso, la única forma de saberlo es medirlo con el analizador de tu empaquetador, no estimarlo.
Elegir la capa
Hay cuatro niveles de herramienta y elegir el correcto es más importante que dominar cualquiera de ellos.
Nada. Un sparkline, una barra de progreso, un gráfico de dos series con un eje. Las escalas del nivel 27 y una plantilla de cadena. Cero dependencias, treinta líneas, control total. Es la respuesta correcta muchas más veces de lo que la gente cree.
D3 para la matemática. Cuando aparecen escalas temporales, formateo con locale, curvas, apilados o jerarquías. Es donde la relación entre lo que ahorras y lo que pesa es mejor. Y es lo que hace hoy la mayoría de la gente que dice «uso D3».
Una capa declarativa sobre D3. Existen bibliotecas de gráficos con una gramática de alto nivel, construidas sobre los mismos módulos, donde un gráfico exploratorio se escribe en cinco líneas. Para explorar datos y para gráficos estándar son un multiplicador enorme, y el precio es que salirse de lo que la gramática expresa cuesta más que no haberla usado.
Una biblioteca de gráficos completa. Cuando necesitas quince tipos de gráfico con leyendas, tooltips, exportación y temas, y ninguno de ellos es especial. Escribir eso con D3 es meses de trabajo que ya está hecho.
El error caro no es elegir mal el primer día: es no volver a plantearse la elección cuando el proyecto cambia. Un panel que empezó con dos gráficos y ahora tiene veinte se ha convertido en una biblioteca de gráficos mal escrita, y ese es el momento de mirar hacia abajo en la lista, no de seguir añadiendo.
Mezclar d3-selection con un framework de interfaz no falla de forma limpia. Falla así:
El framework borra lo que D3 añadió. El componente se vuelve a renderizar por un cambio de estado no relacionado, la conciliación ve un contenedor cuyos hijos no coinciden con los que declaró, y los quita. El gráfico desaparece al pulsar un botón que no tenía nada que ver.
D3 escribe atributos que el framework sobrescribe. Una transición de D3 está animando el atributo height de un rectángulo cuando el framework vuelve a renderizar y escribe el valor declarado. La animación salta al final. De forma intermitente, porque depende de si el renderizado cae dentro de la ventana de la transición.
Los manejadores se registran dos veces. El efecto que hace select(ref.current).on('click', ...) se ejecuta en cada renderizado si su lista de dependencias no es exacta. d3-selection reemplaza el manejador del mismo tipo, lo que oculta el problema con on('click') y lo destapa con on('click.uno') y on('click.dos'), que son manejadores distintos y se acumulan.
Los nodos moribundos sobreviven al desmontaje. Una transición de salida programada con remove sigue viva después de que el framework haya desmontado el componente, y escribe sobre nodos que ya no pertenecen a nadie.
Ninguno de esos cuatro se depura mirando el código: los cuatro dependen del momento exacto en que ocurren dos cosas independientes. Por eso la solución no es «tener cuidado», es un reparto tajante:
El framework es dueño de los elementos. D3 es dueño de los números. Todo lo que sea append, attr, text, join o transition sale del código. Todo lo que sea escala, generador, disposición, interpolador o formateador se queda.
La única excepción legítima es entregarle a D3 un elemento entero, mediante una referencia, para un comportamiento que necesita el DOM: zoom, arrastre o selección rectangular. Y con dos condiciones: que el framework no renderice hijos dentro de ese elemento, y que la limpieza al desmontar quite el comportamiento explícitamente. Si tienes que renderizar contenido dentro, el patrón correcto es que D3 escuche sobre un elemento vacío superpuesto y que el contenido lo pinte el framework debajo.
Un último matiz sobre transiciones, porque es la pérdida real de este enfoque. d3-transition es genuinamente bueno: interpola atributos, encadena, y sabe interpolar cadenas de rutas y colores. Al renunciar a él te quedas con las animaciones de tu framework o con CSS. Para transiciones de posición y opacidad, CSS es suficiente y más barato. Para interpolar la d de una ruta entre dos formas, no lo es, y ahí sí hay que buscar una alternativa concreta o aceptar el reparto de zonas: un contenedor entregado a D3, sin hijos del framework dentro.
Coge un gráfico escrito con el patrón clásico de selectAll, data y join, y reescríbelo separando una función pura de cálculo del render de tu framework. Mide dos cosas antes y después: las líneas de código y el tamaño del paquete final. La primera bajará poco o nada; la segunda bajará mucho, y las pruebas que ahora puedes escribir no aparecen en ninguna de las dos cifras.