Saltar al contenido

reconcile: fusionar datos externos en el store

Cuando datos frescos llegan de fuera —una respuesta de red, un mensaje de socket— traen referencias nuevas con contenido casi idéntico. Reemplazar el store entero destruye la identidad de cada nodo y fuerza a remontar listas y recalcular efectos que no cambiaron. reconcile es el modificador de setter que compara lo viejo con lo nuevo y aplica solo las diferencias, conservando las referencias intactas y salvando la reactividad de grano fino.

El problema: referencias nuevas, contenido viejo

Cuando revalidas una colección, la red te devuelve un objeto recién parseado. Aunque el elemento con id: 1 tenga exactamente el mismo título que ya mostrabas, es un objeto distinto: otra referencia en memoria. Y el store, que rastrea por identidad de referencia, no tiene forma de saber que “es el mismo”: ve un array nuevo cuyas posiciones apuntan a objetos nuevos.

TSX
import { createStore } from "solid-js/store";const [state, setState] = createStore({ todos: [] as Todo[] });// Tras revalidar: reemplazo directo del arraysetState("todos", await cargarTodos()); // array nuevo, objetos nuevos en cada índice

El resultado es un remonte total. <For> está indexado por referencia, así que descarta cada fila vieja y crea una nueva desde cero. Con ella se va el estado del DOM que no vivía en tus signals: el foco del input a medio escribir, la posición de scroll, una transición CSS a mitad de camino, el estado interno de un componente no controlado. Además, todos los memos que leían esas filas se recalculan y todos los efectos se disparan, para acabar pintando exactamente lo mismo que ya estaba en pantalla. Has pagado el precio de un cambio total para no cambiar nada.

Lo insidioso es que este coste es invisible en desarrollo con listas cortas. Diez filas se remontan tan rápido que nadie lo nota; el bug de rendimiento espera agazapado hasta que la lista llega a producción con cientos de elementos y una revalidación cada pocos segundos. Entonces cada refresco convierte una edición local en un recálculo global, y lo que en tu máquina era imperceptible se vuelve un tartamudeo perceptible en la del usuario.

reconcile: diferencia en vez de reemplazo

reconcile no es un valor que guardas: es un modificador del setter. Le pasas los datos nuevos y devuelve una función que el store aplica sobre el estado previo. Por dentro, recorre en paralelo el árbol viejo y el nuevo, empareja los nodos —por key en los arrays de objetos— y escribe solo en las hojas que difieren, dentro de los nodos proxy que ya existían. Los objetos cuyo contenido no cambió conservan su identidad exacta.

TSX
import { createStore, reconcile } from "solid-js/store";const [state, setState] = createStore({ todos: [] as Todo[] });const frescos = await cargarTodos();setState("todos", reconcile(frescos, { key: "id" }));// compara por id, parchea solo las filas que cambiaron y conserva el resto intacto

Desde fuera, state.todos “vale” lo mismo que si lo hubieras reemplazado: contiene los datos frescos. La diferencia es cómo llegó a valerlos. En lugar de sustituir el árbol, reconcile lo mutó quirúrgicamente para que coincida con el destino, tocando el mínimo de nodos posible. Una fila cuyo título cambió recibe una escritura en titulo y nada más; una fila idéntica no recibe ninguna y mantiene su referencia, su nodo del DOM y su lugar en el <For>.

reconcile no está atado a la raíz: como es un modificador de setter, puedes aplicarlo en cualquier ruta del store, tan honda como necesites. Reconcilias la colección entera, una sola fila por su índice, o un subobjeto anidado; el diff se limita al subárbol que le des. Esa composición con la escritura por ruta es lo que te deja ajustar el ámbito al dato que llegó en lugar de reconciliarlo todo por costumbre.

TSX
setState("todos", reconcile(frescos, { key: "id" })); // toda la colecciónsetState("todos", 3, reconcile(unaFila));             // solo la fila del índice 3setState("perfil", "ajustes", reconcile(nuevos));     // un subobjeto anidado

Qué conserva y qué actualiza

La clave mental es que reconcile distingue tres destinos para cada nodo del árbol nuevo: si empareja con uno viejo idéntico, lo conserva sin tocarlo; si empareja con uno viejo distinto, parchea solo los campos que difieren; si no empareja con ninguno, lo crea. Y a la inversa, un nodo viejo sin pareja en el nuevo se elimina.

El coste del diff frente al coste del remonte

reconcile no es gratis: recorrer y comparar los dos árboles es lineal en el tamaño del dato. Pero ese coste es de JavaScript puro sobre objetos en memoria, y compra evitar un coste mucho mayor: el de destruir y reconstruir nodos del DOM, relanzar efectos y perder estado de interfaz. Un diff de mil objetos es barato; remontar mil filas del DOM no lo es. La asimetría casi siempre juega a favor de reconciliar.

El contraste entre las dos escrituras deja la diferencia a la vista. La de arriba parece más simple, pero su simplicidad es engañosa: delega en el runtime un trabajo enorme y destructivo. La de abajo pide explícitamente el diff y, a cambio de ese recorrido, preserva todo lo que puede preservarse.

TSX
// Reemplazo: una línea, pero remonta todo el <For> y relanza cada efectosetState("todos", frescos);// reconcile: una línea, recorre y compara, pero solo toca lo que difieresetState("todos", reconcile(frescos, { key: "id" }));

La regla que interiorizar es que el reemplazo directo de una colección en un store es casi siempre un error de rendimiento latente: funciona, pinta lo correcto, y por eso pasa desapercibido hasta que la lista crece o la revalidación se vuelve frecuente. Reconciliar desde el principio es baratísimo de escribir y te ahorra ese acantilado antes de asomarte a él.

Conviene además recordar que reconcile no es solo para listas: diffea cualquier estructura, incluido un objeto de campos. Fusionar la respuesta de un endpoint que devuelve un único recurso —un perfil, una configuración— también se beneficia, porque solo los campos que cambiaron notifican a sus lectores, y los subobjetos idénticos conservan su identidad.

TSX
const [perfil, setPerfil] = createStore<Perfil>(vacio);async function cargarPerfil() {  const fresco = await fetch("/api/perfil").then((r) => r.json());  setPerfil(reconcile(fresco)); // diffea campo a campo, sin key porque no es lista}

Aquí reconcile se aplica en la raíz del store y compara propiedad por propiedad. Un componente que solo lee perfil.avatar no se entera si únicamente cambió perfil.ultimaConexion. La misma economía de la lista, aplicada a un registro: tocar lo mínimo, notificar lo justo.