Glitch-free: por qué Solid nunca muestra estados intermedios
El problema del diamante y los glitches: valores derivados calculados con una mezcla de entradas viejas y nuevas. Cómo el marcado PENDING, el orden topologico del pull, el contador de ejecucion y la separacion entre cola de memos y cola de efectos garantizan que cada nodo se calcule una vez, con entradas consistentes, y que el DOM solo vea el estado final.
Un glitch es un fantasma: un valor que existe durante un instante, es incoherente, y desaparece antes de que nadie debiera haberlo visto. Aparece cuando un nodo derivado se calcula con una entrada ya actualizada y otra todavía vieja. Muchos sistemas reactivos ingenuos los sufren y los tapan con parches. Solid, en cambio, es glitch-free por construcción: garantiza que ningún efecto, ningún memo y ningún nodo del DOM llega a observar jamás uno de esos estados intermedios. Esta lección explica el mecanismo exacto que lo hace imposible.
- Entender el problema del diamante y cómo nace un glitch.
- Ver cómo el marcado
PENDINGobliga a esperar a que todas las entradas se asienten. - Comprender el orden topológico del pull y el contador de ejecución que evita el doble cálculo.
- Saber por qué los efectos corren después de los memos, y el DOM nunca parpadea.
El problema del diamante
Imagina un signal a del que derivan dos memos, b y c, y un tercer memo d que depende de los dos. El grafo tiene forma de rombo:
flowchart TB A[signal a] --> B[memo b] A --> C[memo c] B --> D[memo d] C --> D style A fill:#89b4fa,color:#11111b style B fill:#cba6f7,color:#11111b style C fill:#cba6f7,color:#11111b style D fill:#cba6f7,color:#11111b
En código, ese rombo es tan corriente como esto —y aparece sin que lo busques en cuanto dos derivaciones comparten un origen—:
const [a, setA] = createSignal(1);
const b = createMemo(() => a() * 2);
const c = createMemo(() => a() + 10);
const d = createMemo(() => b() + c()); // depende de a por DOS caminos
Supón d = b + c. Al cambiar a, tanto b como c deben recomputarse. Un sistema ingenuo que propague en cascada haría esto: cambia a, recomputo b, y como b cambió, recomputo d ya mismo… pero c todavía tiene su valor viejo. Durante ese instante, d vale b_nuevo + c_viejo: un número que nunca debió existir. Luego se recomputa c, se vuelve a recomputar d, y ahora sí queda bien. El resultado final es correcto, pero por el camino d se calculó dos veces y una de ellas fue un glitch. Si un efecto leía d, pudo dispararse con basura.
Pongámosle números. Con a = 1, b = a * 2, c = a + 10 y d = b + c, el estado inicial es b = 2, c = 11, d = 13. Ahora ejecutas setA(5). La secuencia ingenua sería esta:
// setA(5): cascada ingenua, paso a paso
// b recomputa -> 10 (nuevo)
// d recomputa YA -> 10 + 11 = 21 <- GLITCH: mezcla b nuevo con c viejo
// c recomputa -> 15 (nuevo)
// d recomputa OTRA vez -> 10 + 15 = 25 <- por fin, el valor correcto
El 21 es el fantasma: un valor que el usuario podría llegar a ver pintado durante una fracción de milisegundo, y que además costó un cálculo entero de d para nada. Y los glitches no son solo un problema estético. Si en lugar de pintar un número d alimentara un efecto que lanza una petición al servidor o registra un evento de analítica, el cálculo fantasma dispararía una acción de más con datos incoherentes. La consistencia del grafo no va de apariencia: va de no ejecutar efectos secundarios sobre estados que nunca debieron existir. En un grafo con miles de nodos y diamantes anidados, este desperdicio se multiplica.
La cura: PENDING como sala de espera
Solid nunca cae en la cascada inmediata. Como viste en la lección 2, al cambiar a no se recomputa nada: se marca. Los observadores directos b y c quedan STALE; el nodo d, alcanzado por dos caminos, queda PENDING una sola vez —el segundo camino lo encuentra ya marcado y no lo vuelve a encolar—. Solo entonces empieza el pull, y aquí actúa la sala de espera:
// d esta PENDING. Al procesarlo, lookUpstream resuelve sus fuentes primero:
function resolverD(d: Memo<any>) {
for (const fuente of d.sources!) { // b y c
if (fuente.state === 1) runUpdate(fuente); // recomputa la que este STALE
else if (fuente.state === 2) lookUpstream(fuente);
}
// Solo despues de que b Y c esten al dia, d decide si recomputa.
if (d.state === 1) runUpdate(d); // recomputa una sola vez, con ambas frescas
}
La clave: d no se computa mientras alguna de sus fuentes siga sucia. El estado PENDING lo mantiene en la sala de espera hasta que b y c estén ambas resueltas. Cuando por fin corre, lee b_nuevo y c_nuevo a la vez. El valor b_nuevo + c_viejo no llega a materializarse jamás. Y d corre una sola vez, no dos.
Con la sala de espera, la misma escritura setA(5) produce esta traza —los mismos números de antes, sin fantasma—:
// setA(5): con push-then-pull
// PUSH: a marca b y c como STALE; d, alcanzado por dos caminos, queda PENDING una vez
// PULL: se procesa b -> 10 (cambio) -> marca d de PENDING a STALE
// se procesa c -> 15 (cambio) -> d ya estaba STALE, sigue STALE
// se procesa d -> 10 + 15 = 25 <- UNA vez, con b y c ya frescas
El 21 no aparece en ninguna línea, porque d nunca se computa hasta que sus dos entradas están al día. Y donde antes había dos cálculos de d, ahora hay uno. Fíjate en el detalle fino de la lección 2: d pasa de PENDING a STALE en el instante exacto en que b, al recomputarse, produce un valor distinto. Si b hubiera devuelto el mismo número, d seguiría PENDING y, de no cambiar tampoco c, no recomputaría en absoluto. La sala de espera no solo elimina el glitch: elimina también todo el trabajo que no cambia nada.
Si vienes de RxJS, esto contrasta con lo que conoces. Un combineLatest de dos streams derivados de una misma fuente emite un valor por cada emisión de cada rama: el glitch del diamante, por defecto. Evitarlo exige operadores y cuidado manual. La razón de fondo es que un stream de RxJS no sabe que sus dos entradas comparten origen —no hay grafo, hay tuberías independientes que se combinan a ciegas—, mientras que el grafo de Solid conoce la topología completa y puede esperar a que ambas ramas se asienten. Glitch-free es lo que ganas cuando el sistema ve el grafo entero, no tubos sueltos.
Orden topológico sin ordenar el grafo
Para que d no se compute antes que b y c, hace falta respetar el orden topológico del grafo: todo nodo después de sus dependencias. Lo curioso es que Solid no ordena el grafo con un algoritmo de ordenación topológica explícito. El orden correcto emerge de dos mecanismos combinados:
El pull sube antes de bajar
Cuando un nodo PENDING va a computarse, lookUpstream fuerza primero la actualización de sus fuentes sucias. Un nodo nunca corre con una dependencia pendiente por resolver: la resuelve él mismo, hacia arriba, antes de tocarse.
Un contador por tick
Cada computación guarda updatedAt, el número de tick en que corrió por última vez, y hay un contador global ExecCount. Antes de recomputar una fuente, se comprueba que su updatedAt sea anterior al tick actual. Si ya corrió en este tick, no se repite.
Ese contador es el guardián contra el doble cálculo. En el diamante, cuando d resuelve b y luego c, si ambas rutas intentaran actualizar un ancestro común, el updatedAt ya marcado en este tick lo impide: se corre una vez por tick y no más. Entre la sala de espera del PENDING y el sello temporal del updatedAt, cada nodo del grafo se recomputa como mucho una vez por propagación, y siempre después de aquello de lo que depende. Eso es un orden topológico, obtenido sin ordenar nada: pagado con dos enteros por nodo.
Conviene entender por qué basta con esto y no hace falta un ordenamiento topológico global, del estilo del algoritmo de Kahn. Un orden precalculado obligaría a re-ordenar el grafo entero cada vez que cambia una dependencia —y en Solid las dependencias son dinámicas: cambian en cada ejecución según las ramas que se tomen, como viste en el nivel 4—. La resolución perezosa hacia arriba descubre el orden correcto sobre la marcha, solo para el subgrafo realmente afectado por la escritura y solo cuando hace falta computar. Es orden topológico bajo demanda, la única clase de orden que sobrevive a un grafo que muta en cada tick.
ExecCount funciona como un reloj lógico del grafo: se incrementa una vez por ciclo de propagación, y cada nodo estampa en updatedAt el instante en que corrió. Comparar ambos responde en tiempo constante a la pregunta “¿ya me actualicé en este ciclo?”, sin banderas que haya que limpiar después. Es el mismo truco que usan los recolectores de basura por épocas: en vez de recorrer el grafo poniendo a cero un flag visitado, avanzas el reloj y das por no-visitado todo lo que tenga un sello antiguo. Barato de comprobar, imposible de olvidar limpiar.
El glitch-freedom protege el grafo reactivo, no tu descuido. Si lees un signal dentro de un setTimeout o mezclas estado reactivo con una variable mutable normal, sales del grafo y pierdes la garantía: ahí sí puedes observar valores intermedios, porque ese código corre fuera de la coreografía del pull. La regla práctica: deja que las derivaciones vivan en memos y efectos, y no saques valores del grafo para recombinarlos a mano de forma asíncrona.
Los efectos, al final: el DOM no parpadea
Queda la última pieza, la que protege lo que el usuario ve. Recuerda las dos colas de la lección 2: los memos (pure) van a Updates, los efectos van a Effects. Al cerrar el batch, Solid drena primero toda la cola de memos y solo después la de efectos:
function completeUpdates() {
runQueue(Updates); // 1) todos los memos se asientan: el grafo puro converge
const e = Effects;
Effects = null;
runQueue(e); // 2) recien ahora corren efectos: DOM, red, logs
}
Un efecto —que toca el DOM, lanza una petición o escribe un log— nunca corre en mitad de la convergencia. Corre cuando todos los memos ya alcanzaron su valor final. Por eso el DOM salta directo del estado viejo al nuevo, sin fotogramas intermedios: cuando el efecto de render se ejecuta, d ya es coherente. La separación de colas es lo que extiende la coherencia del grafo puro al mundo de los efectos secundarios.
Este drenaje ocurre de forma síncrona al cerrar el lote de escrituras. Varias escrituras dentro de un mismo manejador de evento —o envueltas en batch— se acumulan sin disparar nada, y al soltarlas se procesa una sola onda push-pull con todos los valores nuevos a la vez. Por eso no observas resultados a medias entre dos setX consecutivos del mismo manejador: el grafo no despierta hasta que sueltas el lote. Un diamante con sus dos ramas escritas en el mismo batch se resuelve exactamente igual que si una sola fuente hubiera cambiado: una convergencia, un repintado, cero fantasmas.
Hay un regalo práctico escondido en este orden. Cuando un efecto de layout mide el DOM —lee un offsetHeight, coloca un tooltip, ajusta un scroll—, lo hace siempre sobre el estado final, nunca sobre uno a medio construir, porque para cuando corre ya convergió todo el grafo puro. La misma coreografía que impide glitches entre memos es la que te deja medir el DOM con la certeza de que lo que lees es lo definitivo.
No hay en el núcleo de Solid una línea que diga “evita glitches”. La ausencia de estados intermedios es una propiedad emergente de tres decisiones que ya tenían otras razones de ser. El marcado PENDING existía para no recalcular de más —lección 2— y de paso obliga a esperar a que las entradas se asienten. El pull hacia arriba existía para computar bajo demanda y de paso impone el orden topológico. La separación entre cola de memos y cola de efectos existía para agrupar el trabajo y de paso garantiza que los efectos solo ven el estado final. Junta las tres y obtienes, gratis, una garantía que otros sistemas persiguen con maquinaria dedicada. Esto es lo que distingue un diseño profundo de un montón de parches: las buenas propiedades no se añaden, se deducen. En 2026 esta misma coreografía —marcar en dos colores, resolver hacia arriba, correr efectos al final— se ha convertido en el estándar de facto del ecosistema; la propuesta de signals de TC39 la describe como graph coloring con nodos limpios, a comprobar y sucios, exactamente el CLEAN, PENDING y STALE que acabas de ver. Solid no inventó el diamante, pero su solución es una de las más limpias que existen: consistencia por construcción, no por vigilancia.
- Monta el diamante
a -> b, c -> dcond = b() + c(). Añade unconsole.logendy en un efecto que lo lea. Cambiaay cuenta:ddebe computarse una vez, nunca dos. - Imprime dentro de
dlos valores deb()yc(). Confirma que jamás aparece la combinación viejo/nuevo. - Ahora rompe la garantía a propósito: en el efecto, en vez de leer
d(), leeb()yc()por separado dentro de unsetTimeout. Observa cómo, fuera del grafo, sí puedes capturar un estado incoherente. - Explica con tus palabras el papel de
updatedAten impedir que un ancestro común se recompute dos veces en el mismo tick. - Razona por qué mover un efecto a la cola de memos (o al revés) rompería la garantía de que el DOM no parpadea.
- Con
a=1,b=a*2,c=a+10,d=b+c, traza a mano el estado deb,cydtrassetA(5)en el esquema ingenuo y en el push-pull. Localiza el valor fantasma21en el primero y su ausencia en el segundo.