wandres.dev
EL GRAFO REACTIVO · observers y sources

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.

⏱ 15 min

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.

🎯 Al terminar esta lección sabrás
  • Entender el problema del diamante y cómo nace un glitch.
  • Ver cómo el marcado PENDING obliga 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.

ℹ️
Los observables clásicos SÍ tienen glitches

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.

📝
El contador de ejecución es un reloj lógico

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.

⚠️
Fuera del grafo no hay garantia

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.

El glitch-freedom no es una funcion: es una consecuencia

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.

⚔️ Provoca un glitch (y comprueba que Solid no te deja)
  1. Monta el diamante a -> b, c -> d con d = b() + c(). Añade un console.log en d y en un efecto que lo lea. Cambia a y cuenta: d debe computarse una vez, nunca dos.
  2. Imprime dentro de d los valores de b() y c(). Confirma que jamás aparece la combinación viejo/nuevo.
  3. Ahora rompe la garantía a propósito: en el efecto, en vez de leer d(), lee b() y c() por separado dentro de un setTimeout. Observa cómo, fuera del grafo, sí puedes capturar un estado incoherente.
  4. Explica con tus palabras el papel de updatedAt en impedir que un ancestro común se recompute dos veces en el mismo tick.
  5. 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.
  6. Con a=1, b=a*2, c=a+10, d=b+c, traza a mano el estado de b, c y d tras setA(5) en el esquema ingenuo y en el push-pull. Localiza el valor fantasma 21 en el primero y su ausencia en el segundo.