Saltar al contenido

Igualdad estructural y el límite del signal

Comparar estructuras cuesta, y un signal sobre un objeto grande obliga a reemplazarlo entero y a notificar a todos sus lectores aunque solo cambie un campo. Cuando el estado es un árbol profundo leído por muchos, el signal es la granularidad equivocada: el store restaura la reactividad de grano fino con proxies por propiedad.

El coste de la igualdad estructural

Cuando un signal guarda objetos y quieres que compare por contenido, escribes un equals estructural. Pero comparar estructuras es intrínsecamente caro: recorrer campo a campo es lineal en el tamaño del dato, y ese recorrido se ejecuta en cada escritura. Una comparación profunda sobre un árbol grande puede costar más que el trabajo que pretendía ahorrar aguas abajo.

TypeScript
// Comparacion profunda: O(n) en cada escritura, se descarte o no el valorconst [doc, setDoc] = createSignal(inicial, {  equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), // caro y fragil});// Preferible: compara una version o un hash en tiempo constanteconst [doc2, setDoc2] = createSignal(inicial, {  equals: (a, b) => a.rev === b.rev,});

Comparar por rev, por un hash o por una versión monótona te da la potencia de igualar por contenido a coste constante. Pero incluso el mejor comparador no resuelve el problema de fondo, que no es cómo comparo el valor, sino que el valor entero es una sola unidad de reactividad.

Conviene separar dos costes que a menudo se confunden. La comparación por defecto de un signal sobre un objeto es ===, que mira solo la referencia: es tan barata sobre un árbol gigante como sobre un entero, porque no lo recorre. El coste lineal aparece únicamente cuando tú impones un equals estructural. Dicho de otro modo: guardar una estructura grande en un signal no es caro de comparar; lo caro es pedirle que compare por contenido. Y como esa comparación cara solo mitiga a medias el problema real, la conclusión se adelanta sola: el obstáculo no es la igualdad, es la granularidad.

El signal como cuello de botella de granularidad

Un signal es atómico: leerlo suscribe al valor completo, y cualquier cambio notifica a todos sus lectores. Si guardas un objeto con veinte campos y veinte componentes leen uno cada uno, cambiar un solo campo obliga a reemplazar el objeto entero y despierta a los veinte, aunque diecinueve leyeran algo que no se tocó. El signal no puede expresar dependo solo de estado.a: solo sabe dependo del estado.

Esta es la asimetría clave: el signal tiene una granularidad de un nodo por valor, y un objeto grande es un solo valor. Toda la reactividad de grano fino que hace a Solid especial se colapsa cuando metes una estructura entera en un único signal, porque reduces mil dependencias potenciales a una sola arista gruesa.

Un formulario grande lo hace tangible. Imagina un estado con treinta campos y treinta inputs, cada uno leyendo y escribiendo el suyo. Con un signal, teclear una letra en un campo reemplaza el objeto entero y notifica a los treinta inputs: veintinueve se reconcilian sin que su valor haya cambiado. La interfaz sigue funcionando —Solid actualiza el DOM con eficiencia— pero has convertido una edición local en un recálculo global, y a suficiente escala eso se nota en cada pulsación. El signal no es lento; es que le pediste que modelara treinta dependencias con una sola.

TSX
// Signal: un objeto es una sola unidad de notificacionconst [form, setForm] = createSignal({ nombre: "", email: "" });setForm((f) => ({ ...f, nombre: v })); // reemplaza el todo, despierta a TODOS// Store: cada campo es su propia dependencia rastreadaconst [campos, setCampos] = createStore({ nombre: "", email: "" });setCampos("nombre", v); // solo se entera quien lee campos.nombre

El store: grano fino sobre estructuras

createStore resuelve esto envolviendo la estructura en un proxy que rastrea por propiedad, incluso en profundidad. Leer estado.usuario.nombre suscribe solo a esa hoja; escribir en ella notifica solo a quien la lee. Cada propiedad se comporta como su propio signal, creado de forma perezosa la primera vez que alguien la lee.

Esa pereza importa: el store no crea mil nodos por adelantado, sino que instala el signal de una propiedad solo cuando alguien la lee por primera vez. Pagas granularidad únicamente por lo que de verdad observas, y las ramas que nadie mira no cuestan nada. Es la inversión exacta del signal grueso: allí, una lectura te suscribe a todo; aquí, cada lectura te suscribe con precisión de bisturí a la hoja concreta que tocaste.

TSX
import { createStore, produce, reconcile } from "solid-js/store";const [estado, setEstado] = createStore({  usuario: { nombre: "Ada", rol: "admin" },  items: [] as Item[],});setEstado("usuario", "nombre", "Grace"); // notifica solo a los lectores de nombresetEstado("items", (arr) => [...arr, nuevo]); // rama items, sin tocar usuariosetEstado(produce((s) => { s.usuario.rol = "editor"; })); // estilo mutable, rastreadosetEstado("items", reconcile(desdeServidor)); // fusiona conservando identidad

Las tres herramientas de escritura cubren el espectro. La escritura por ruta es granular y declarativa. produce te deja escribir con apariencia de mutación sobre un borrador proxy que registra cada cambio. Y reconcile es el que brilla al recibir datos del servidor: en lugar de reemplazar el array entero —que remontaría todo un <For>— compara con lo que había y aplica solo las diferencias, conservando la identidad de las filas que no cambiaron.

reconcile merece un segundo vistazo porque resuelve la tensión que abría esta lección. Cuando revalidas datos de red, recibes referencias completamente nuevas con contenido casi idéntico; reemplazar sin más haría que todo el árbol se considerara cambiado. reconcile recorre el árbol viejo y el nuevo emparejando por clave, y solo escribe en las hojas que de verdad difieren, dejando intactas —con su misma identidad— las que coinciden. Así el <For> conserva sus filas, los efectos que dependían de campos inmutados no se disparan, y la reactividad de grano fino sobrevive al cruce con el mundo exterior de los datos remotos.

Dónde está la frontera

La decisión no es de gusto sino de forma del cambio. Un valor que reemplazas entero cada vez —un contador, una bandera, un modo, una referencia opaca— es atómico por naturaleza: signal. Un árbol donde editas partes independientes que distintos lectores observan por separado —un formulario grande, un documento, una lista de objetos editables uno a uno— es granular por naturaleza: store.

El error simétrico también existe: no metas un simple booleano en un store por costumbre —es maquinaria de proxy para nada— ni acorrales un documento entero en un signal por inercia. La pregunta que zanja el caso es una: ¿mis lectores dependen del valor completo o de partes independientes de él? Si es del completo, signal; si de partes, store.

Hay además un caso mixto que la experiencia enseña a reconocer: no es infrecuente combinar ambos. Una colección grande vive en un store para editarse por elemento, mientras un puñado de estados de UI atómicos —el elemento seleccionado, un modo de vista, una bandera de carga— viven en signals sueltos junto a él. No estás obligado a elegir un único recipiente para toda la aplicación; eliges por cada pieza de estado según su forma. Un id seleccionado es un átomo aunque apunte a un objeto del store: lo que reemplazas entero es el id, no el objeto.