El join de datos: enter, update y exit
El patrón que hizo famoso a D3, qué problema resolvía en 2011, la función de clave y la constancia de objeto, y por qué en 2026 tu framework ya hace ese trabajo.
El join de datos es un algoritmo de diferencias entre un array y un conjunto de nodos del DOM, escrito dos años antes de que existiera el DOM virtual. Entenderlo importa por tres razones muy concretas, y ninguna de ellas es que vayas a escribirlo: vas a leer código que lo usa, la constancia de objeto que resuelve es un problema que sigue vivo en cualquier framework, y d3-transition no funciona sin él.
- Describir las tres subselecciones que produce un join y qué contiene cada una.
- Escribir un join moderno con
joiny explicar qué hace por debajo. - Usar una función de clave y justificar la constancia de objeto.
- Identificar el fallo del índice como clave y sus síntomas visuales.
El problema que resolvía
En 2011 no había DOM virtual. Actualizar un gráfico cuando cambiaban los datos tenía dos salidas, y las dos eran malas.
Borrar y reconstruir. contenedor.innerHTML = '' y volver a pintar. Simple, correcto y destructivo: se pierde el foco del teclado, se pierde la posición del scroll, se cancela cualquier transición en curso, y sobre todo se pierde la identidad de los elementos. Un elemento que existía antes y sigue existiendo después es un nodo nuevo, así que el navegador no puede animar su cambio: no sabe que es el mismo.
Escribir el diff a mano. Recorrer los datos nuevos, buscar el nodo correspondiente, crear los que faltan, actualizar los que están y borrar los que sobran. Correcto y tedioso, y ese bucle se reescribe entero cada vez que cambia la estructura de los datos.
El join es la tercera salida: una operación que empareja un array de datos con un conjunto de nodos y te entrega las tres diferencias ya calculadas.
const seleccion = svg.selectAll('rect').data(datos, d => d.id);
Esa llamada divide el mundo en tres:
- update: la propia
seleccion, los datos que ya tenían nodo. Hay que actualizarlos. - enter:
seleccion.enter(), los datos sin nodo. Hay que crearlos. - exit:
seleccion.exit(), los nodos sin dato. Hay que quitarlos.
Es exactamente la conciliación que hace un DOM virtual, expuesta como tres colecciones en lugar de escondida detrás de un render. Y la palabra join no es casual: es el join de una base de datos entre la tabla de los datos y la tabla de los nodos, con la función de clave haciendo de condición de igualdad.
La forma antigua y la moderna
El patrón clásico, el que aparece en todos los ejemplos anteriores a 2019:
const barras = svg.selectAll('rect').data(datos, d => d.id);
barras.exit().remove();
barras.enter()
.append('rect')
.attr('fill', '#89b4fa')
.merge(barras) // union de enter y update
.attr('x', d => x(d.mes))
.attr('y', d => y(d.valor))
.attr('width', x.bandwidth())
.attr('height', d => alto - y(d.valor));
El merge es la pieza que confunde a todo el mundo: los atributos que solo se ponen al crear van antes, los que hay que actualizar siempre van después. Olvidar el merge produce un gráfico que se dibuja bien la primera vez y no se mueve nunca más, que es el bug número uno de D3.
Desde la versión 5.8 hay una forma mejor:
svg.selectAll('rect')
.data(datos, d => d.id)
.join('rect')
.attr('x', d => x(d.mes))
.attr('y', d => y(d.valor))
.attr('width', x.bandwidth())
.attr('height', d => alto - y(d.valor))
.attr('fill', '#89b4fa');
join hace las tres cosas: crea los que faltan, quita los que sobran y devuelve la unión de entrantes y actualizados. La versión de tres funciones da control cuando hace falta:
svg.selectAll('rect')
.data(datos, d => d.id)
.join(
entra => entra.append('rect')
.attr('y', alto) // nacen desde la linea base
.attr('height', 0),
actualiza => actualiza,
sale => sale.transition().duration(300)
.attr('height', 0)
.attr('y', alto)
.remove()
)
.transition().duration(300)
.attr('x', d => x(d.mes))
.attr('y', d => y(d.valor))
.attr('height', d => alto - y(d.valor));
Ahí está el valor real del patrón: las tres transiciones son distintas. Lo que entra crece desde la base, lo que sale se encoge hacia la base, y lo que se queda se desliza a su nueva posición. Con un borrado y reconstrucción, las tres serían la misma: aparecer de la nada.
La clave y la constancia de objeto
El segundo argumento de data es la función de clave, y omitirla es el error más consecuente de todo el patrón.
Sin clave, D3 empareja por índice: el dato 0 con el nodo 0, el dato 1 con el nodo 1. Mientras los datos solo se añadan al final, funciona. En cuanto se borra un elemento del medio, o se reordenan, cada dato se ata a un nodo que representaba otra cosa.
// datos antes: [A, B, C, D] -> cuatro barras
// datos despues:[A, C, D] -> se borra B
// Sin clave: el nodo de B pasa a representar a C,
// el de C a D, y el de D se va al exit.
// Con clave: el nodo de B se va al exit,
// los de C y D no se tocan.
Visualmente la diferencia es enorme. Sin clave, borrar la segunda barra de cuatro produce una animación en la que las barras segunda, tercera y cuarta cambian de altura a la vez y desaparece la última: el ojo ve «los datos han cambiado». Con clave, se ve exactamente lo que ha pasado: la segunda barra se encoge y desaparece, y las demás se desplazan.
Eso es la constancia de objeto: que el mismo dato esté representado por el mismo elemento gráfico a lo largo del tiempo, de modo que el lector pueda seguirlo con la vista. Heer y Robertson lo estudiaron experimentalmente en 2007, en un trabajo sobre transiciones animadas en gráficos estadísticos, y midieron que las transiciones que preservan la identidad de los objetos mejoran de forma significativa la capacidad de los sujetos para seguir cambios en los datos frente a los cambios instantáneos.
La regla es corta: pon siempre una función de clave, y que devuelva un identificador estable del dato, no su posición.
.data(datos, d => d.id) // bien
.data(datos, (d, i) => i) // es lo mismo que no poner nada
.data(datos, d => d.nombre) // bien si los nombres son unicos y estables
.data(datos, d => JSON.stringify(d)) // mal: cambia cuando cambia el valor
La última merece explicación: si la clave incluye el valor, un dato que cambia de valor se convierte en un dato distinto, así que sale por exit y entra por enter. El resultado es que nada se anima nunca, porque cada actualización es un reemplazo completo.
Y esto no es folclore de D3: es exactamente el atributo key de React, keyed each de Svelte y v-for con :key de Vue. El mismo problema, la misma solución, la misma trampa del índice. Si has entendido el join, has entendido por qué tu framework te avisa cuando falta la clave.
Este es el bug que aparece cuando el join ya funciona, cuando las animaciones ya están puestas, y cuando los datos empiezan a actualizarse rápido. Es difícil de reproducir y trivial de entender una vez visto.
sale.transition().duration(300).remove() no quita el nodo: programa su retirada para dentro de 300 milisegundos. Durante esos 300 milisegundos el nodo sigue en el DOM, con su dato antiguo atado.
Ahora llega una actualización a los 100 milisegundos, con un dato cuya clave es la del nodo que se está muriendo. El selectAll recoge ese nodo, el join lo empareja por clave, lo mete en la subselección de actualización… y a los 200 milisegundos la transición de salida que seguía viva ejecuta su remove y lo borra. El dato existe, no tiene nodo, y nadie lo va a crear porque el join ya pasó. Falta una barra.
La variante contraria es aún más común: el nodo moribundo se queda con height a cero mientras un nodo nuevo se crea para el mismo dato, y acabas con dos elementos superpuestos que se van acumulando. En un panel que refresca cada segundo, el DOM crece sin parar.
Las tres defensas, en orden de robustez:
Uno: interrumpir antes de unir. seleccion.interrupt() cancela las transiciones activas sobre esos nodos. Aplicado a la selección completa antes del join, elimina la mayor parte del problema, y es una línea.
Dos: excluir los moribundos de la selección. Marcar los nodos que salen con una clase y seleccionar con selectAll('rect:not(.saliendo)'). Así el join nunca ve un nodo que ya está condenado, y el dato entra limpio por enter. Es la defensa que usan las bibliotecas serias.
Tres: no animar la salida. sale.remove() sin transición. Menos bonito, imposible de romper. Para un panel que se refresca varias veces por segundo es directamente la decisión correcta, porque a esa frecuencia nadie ve las transiciones de todas formas.
Y la observación general, que vale más allá de D3: cualquier animación de salida crea un intervalo en el que un elemento existe visualmente pero ya no existe lógicamente. Los frameworks tienen el mismo problema y lo resuelven con la misma familia de trucos. Si tu framework tiene un componente de transición de salida, esa es exactamente la ventana en la que tu estado y tu DOM discrepan.
Monta un gráfico de barras con join por clave y un botón que borre un elemento del medio. Grábalo en vídeo con la clave puesta y sin ella, y compara las dos animaciones fotograma a fotograma. Después reduce la duración de la transición de salida a dos segundos y pulsa el botón cinco veces seguidas: verás aparecer el bug de los nodos moribundos sin necesidad de provocarlo.