wandres.dev
SIGNALS AVANZADOS · equals y opciones

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.

⏱ 16 min

El signal es la primitiva perfecta para un valor atómico: un número, un booleano, una cadena, una referencia que reemplazas entera. Pero en cuanto lo cargas con un árbol de datos leído por medio interfaz, empieza a rozar sus límites: comparar el árbol cuesta, y peor aún, el signal solo sabe notificar todo o nada. Este es el momento en que un ingeniero maduro reconoce que la herramienta correcta ha dejado de ser el signal y ha pasado a ser el store.

🎯 Al terminar esta lección sabrás
  • Medir el coste de la igualdad estructural frente a hash o versión.
  • Ver el signal sobre un objeto grande como un cuello de botella de granularidad.
  • Entender cómo un store rastrea por propiedad con proxies.
  • Situar la frontera: valores atómicos al signal, estructuras al store.

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.

// Comparacion profunda: O(n) en cada escritura, se descarte o no el valor
const [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 constante
const [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 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.

flowchart TD
subgraph Signal grueso
  SG[signal objeto entero] --> RA[componente lee campo a]
  SG --> RB[componente lee campo b]
  SG --> RC[componente lee campo c]
end
subgraph Store fino
  PA[prop a] --> QA[componente lee campo a]
  PB[prop b] --> QB[componente lee campo b]
  PC[prop c] --> QC[componente lee campo c]
end
style SG fill:#f38ba8,color:#11111b
style PA fill:#a6e3a1,color:#11111b
style PB fill:#a6e3a1,color:#11111b
style PC fill:#a6e3a1,color:#11111b

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.

// Signal: un objeto es una sola unidad de notificacion
const [form, setForm] = createSignal({ nombre: "", email: "" });
setForm((f) => ({ ...f, nombre: v })); // reemplaza el todo, despierta a TODOS

// Store: cada campo es su propia dependencia rastreada
const [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.

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 nombre
setEstado("items", (arr) => [...arr, nuevo]); // rama items, sin tocar usuario
setEstado(produce((s) => { s.usuario.rol = "editor"; })); // estilo mutable, rastreado
setEstado("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.

ℹ️
Store y signal comparten el mismo motor reactivo

Un store no es un sistema paralelo: por debajo, cada propiedad rastreada es un signal, y la propagación glitch-free, el batching y equals funcionan igual que ya conoces. La diferencia es puramente de granularidad y ergonomía: el store automatiza la creación perezosa de un signal por hoja y la escritura por ruta que reconstruye solo el camino tocado. Entender que es el mismo motor te libera de verlo como una API ajena y te deja razonar sobre él con el modelo mental que ya tienes de los signals.

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.

⚛️

Signal

Un valor atómico con observadores. Leerlo suscribe al todo; cambiarlo notifica a todos. Perfecto para primitivas y referencias que reemplazas enteras.

🌳

Store

Un proxy de grano fino. Leer una ruta suscribe solo a esa hoja; escribirla notifica solo a sus lectores. Para árboles profundos con campos independientes.

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.

⚠️
El store no sustituye al signal: cambia la granularidad

No hay una primitiva mejor y otra peor; hay dos granularidades para dos formas de estado. Un signal es un átomo reactivo: indivisible, notifica en bloque. Un store es un árbol reactivo: divisible, notifica por hoja. Meter estructura en un signal desperdicia la reactividad de grano fino de Solid colapsándola en una arista gruesa; meter un átomo en un store paga el coste del proxy sin usar su granularidad. La destreza está en leer la forma del estado antes de elegir el recipiente, no en preferir uno por defecto.

Elige el primitivo por la forma del cambio, no por el tamaño del dato

La frontera entre signal y store no la marca cuántos bytes pesa el estado, sino la topología de sus cambios y de sus lecturas. Pregúntate cómo cambia el dato y quién lo mira. Si cambia como un todo —lo reemplazas entero, y quien lo lee depende del conjunto— entonces es atómico, y forzar granularidad sobre él es maquinaria inútil: un signal lo modela con exactitud, notificando en bloque porque en bloque es como cambia. Pero si cambia por partes —tocas un campo y dejas los demás quietos, y distintos observadores miran distintas ramas— entonces es un árbol, y modelarlo con un signal es un error de granularidad que colapsa mil dependencias finas en una sola arista gruesa: cada edición de una hoja despierta a todos los lectores del tronco, la igualdad estructural que intentas para mitigarlo cuesta un recorrido lineal por escritura, y el reemplazo inmutable te obliga a reconstruir cada nivel del camino. El store existe precisamente para esa forma: sus proxies restauran la reactividad de grano fino propiedad a propiedad, la escritura por ruta evita el spread en escalera, produce te devuelve la ergonomía de mutar sin perder el rastreo, y reconcile fusiona datos externos conservando identidad para que tus listas no remonten enteras. La madurez consiste en dejar de preguntar cuál primitiva es más potente y empezar a preguntar cuál es la forma de este cambio: átomo o árbol. Respondida esa pregunta, la elección del recipiente deja de ser una preferencia y se vuelve una consecuencia.

⚔️ Lee la forma del estado
  1. Guarda un objeto de diez campos en un signal y haz que diez efectos lean uno cada uno; cambia un campo y cuenta cuántos efectos se disparan.
  2. Reescribe ese estado con createStore y repite; confirma que solo se despierta el lector del campo tocado.
  3. Añade a la versión con signal un equals estructural y mide su coste con un console.count; compáralo con comparar una rev.
  4. Con reconcile, fusiona una respuesta simulada del servidor en una lista y comprueba en las devtools que las filas sin cambios no se remontan.
  5. Justifica por escrito, para tres estados reales de tu app, si cada uno es un átomo (signal) o un árbol (store), citando cómo cambia y quién lo lee.