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.
Un store te da reactividad de grano fino: cada hoja es su propio signal y solo despierta a quien la lee. Pero esa granularidad se sostiene sobre la identidad de los nodos, y la identidad es justo lo que se pierde cuando datos frescos llegan del exterior. Una respuesta de red trae un objeto nuevo, con arrays nuevos y objetos nuevos dentro, aunque el noventa por ciento de su contenido sea idéntico al que ya tenías. Reemplazar el store con ese objeto —setStore("todos", nuevo)— es tirar toda esa identidad a la basura: cada fila de tu <For> se remonta, cada memo se recalcula, cada efecto se dispara, aunque nada visible haya cambiado. reconcile existe para evitar esa catástrofe silenciosa.
- Entender por qué reemplazar un store con datos nuevos destruye la identidad y colapsa la reactividad fina.
- Usar
reconcilecomo modificador del setter para fusionar por diferencia en lugar de reemplazar. - Ver cómo conserva las referencias de los nodos que no cambian y solo escribe en las hojas que difieren.
- Reconocer su efecto sobre
<For>, memos y efectos: no se remonta ni recalcula lo que permaneció igual.
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.
import { createStore } from "solid-js/store";
const [state, setState] = createStore({ todos: [] as Todo[] });
// Tras revalidar: reemplazo directo del array
setState("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.
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.
setState("todos", reconcile(frescos, { key: "id" })); // toda la colección
setState("todos", 3, reconcile(unaFila)); // solo la fila del índice 3
setState("perfil", "ajustes", reconcile(nuevos)); // un subobjeto anidado
reconcile se importa de solid-js/store y solo tiene sentido aplicado a un store, porque necesita un proxy con nodos rastreados que poder parchear hoja por hoja. Sobre un signal plano no hay nada que reconciliar: un signal es atómico, cambia entero o no cambia. Si tu estado remoto es una estructura leída por partes, esa es justamente la señal de que debía vivir en un store y no en un signal.
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.
flowchart TD
N[llega el arbol nuevo] --> M{empareja por key con uno viejo}
M -->|si e identico| C[conserva la referencia intacta]
M -->|si pero difiere| P[parchea solo las hojas distintas]
M -->|no empareja| A[crea el nodo nuevo]
V[nodo viejo sin pareja] --> D[elimina]
style C fill:#a6e3a1,color:#11111b
style P fill:#f9e2af,color:#11111b
style A fill:#89b4fa,color:#11111b
style D fill:#f38ba8,color:#11111bIdentidad conservada
Las filas sin cambios mantienen su referencia, su nodo del DOM y su posición en el <For>. No se remontan.
Parche mínimo
Solo las hojas que de verdad difieren reciben una escritura; los efectos que dependían de campos inmutados no se disparan.
Estado del DOM a salvo
Foco, scroll, selección y transiciones en curso sobreviven porque no se destruye ni recrea el nodo que los alberga.
Coste acotado
El diff es lineal en el tamaño del dato, pero evita el coste muy superior de destruir y reconstruir nodos del DOM.
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.
// Reemplazo: una línea, pero remonta todo el <For> y relanza cada efecto
setState("todos", frescos);
// reconcile: una línea, recorre y compara, pero solo toca lo que difiere
setState("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.
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.
Todo el modelo de grano fino de Solid descansa sobre una premisa tan silenciosa que es fácil no verla: que un nodo es el mismo a lo largo del tiempo. La fila que renderizaste, el efecto que la observa, la hoja del store que la respalda, comparten una identidad de referencia que persiste mientras el dato conceptualmente lo hace. Cuando revalidas contra un servidor, el mundo exterior no respeta esa identidad —no puede, porque habla en instantáneas inmutables: cada respuesta es un objeto nuevo, coherente consigo mismo pero ajeno a lo que ya tenías—. Ahí se abre un abismo entre dos ontologías: la de fuera, donde el estado es una sucesión de valores completos que se reemplazan; y la de dentro, donde el estado es un grafo de nodos con identidad que se mutan por partes. Reemplazar sin más es dejar que la ontología de fuera arrase con la de dentro: cada instantánea nueva declara todo distinto y la reactividad fina, que solo sabía brillar mientras las cosas conservaban su identidad, se derrumba en un remonte global. reconcile es el puente entre las dos ontologías. Recibe la instantánea inmutable de fuera y, en vez de dejarla reemplazar el grafo, la usa como plano de a qué debe parecerse el grafo, y lo transforma mutándolo hoja por hoja hasta que coincide, preservando toda la identidad que puede por el camino. No traduce valores: traduce entre dos maneras incompatibles de entender qué es el estado en el tiempo. Por eso es la primitiva que hace habitable la frontera entre el cliente reactivo y el servidor inmutable, y por eso dominarla es dominar el punto exacto donde tu aplicación toca el mundo.
- Monta una lista de diez elementos en un store y píntala con
<For>; da a cada fila un input no controlado y escribe algo en uno. - Reemplaza el array con
setState("todos", copiaFresca)dondecopiaFrescaes un clon con el mismo contenido; confirma que el texto del input se pierde porque la fila se remontó. - Repite el paso 2 pero con
setState("todos", reconcile(copiaFresca, { key: "id" }))y comprueba que el input conserva su texto. - Cambia un solo campo de una fila en
copiaFrescay verifica en las devtools que únicamente esa fila se actualiza. - Aplica
reconcilesobre un objeto de perfil en la raíz del store y confirma que un lector de un campo inmutado no se despierta al cambiar otro.