wandres.dev
DEPURAR LA REACTIVIDAD · por qué no se actualiza

No se actualiza: el árbol de diagnóstico completo

La queja más frecuente del desarrollo reactivo es también la menos informativa: no dice qué eslabón de la cadena se rompió, solo que el resultado decepcionó. Esta lección convierte esa frase en un árbol de diagnóstico formal, una secuencia de preguntas binarias que en cuatro nodos reduce todo el espacio de sospechosos a uno solo. La raíz pregunta si el dato cambió en la fuente, y separa el fallo de reactividad del fallo de flujo que lo precede. El segundo nodo pregunta si cambió la identidad del valor, y ahí aparece la mutación en sitio: el dato correcto que ningún sistema basado en comparación por referencia puede detectar. El tercero pregunta si la lectura ocurrió dentro del contexto de rastreo, y ahí vive la suscripción que nunca se estableció. El cuarto absuelve al estado y acusa a la vista. El orden de las preguntas está calculado para que cada respuesta elimine la mitad del bosque.

⏱ 18 min

De todas las frases que se pronuncian frente a una pantalla, «no se actualiza» es a la vez la más frecuente y la menos informativa. No describe un fallo: describe una decepción. Entre el gesto del usuario y el pixel que se negó a cambiar hay una cadena de al menos cuatro eslabones —la escritura del dato, la detección del cambio, la notificación a quien lo escucha y el repintado de la vista— y la queja no dice cuál de los cuatro se rompió. Por eso el instinto que la sigue, abrir el componente que no se pintó y mirarlo fijamente, es casi siempre una pérdida de tiempo: el componente es el último eslabón, y el fallo suele estar tres pasos antes. Depurar reactividad consiste, antes que nada, en negarse a adivinar. Consiste en transformar esa frase difusa en una pregunta con respuesta binaria, y esa respuesta en otra pregunta, hasta que el espacio de sospechosos se reduzca a uno. Esta lección construye ese árbol completo, desde la raíz hasta las dos hojas que concentran la inmensa mayoría de los casos reales: la mutación en sitio que ningún sistema pudo detectar, y la suscripción que nunca llegó a existir.

🎯 Al terminar esta lección sabrás
  • Convertir la queja «no se actualiza» en una secuencia ordenada de preguntas con respuesta binaria.
  • Separar los cuatro eslabones de la cadena reactiva y localizar en cuál se interrumpió la propagación.
  • Diagnosticar la mutación en sitio como un fallo de detección, no como un defecto de la librería.
  • Reconocer la suscripción ausente por su firma: la lectura que ocurrió fuera del contexto de rastreo.
🔍

El dato nunca cambió

El manejador no llegó a ejecutarse, una guarda lo cortó antes, o escribiste sobre una instancia distinta del store. El fallo es anterior a la reactividad.

🧬

Cambió, pero por dentro

El valor es correcto y la referencia es la misma. La comparación por identidad no ve nada, así que no hay nada que notificar.

🔌

Cambió y nadie escuchaba

Hubo escritura y hubo notificación, pero tu lectura ocurrió fuera del contexto que registra dependencias. Nunca fuiste suscriptor.

Nodo uno: la raíz pregunta por el dato, nunca por la vista

Antes de recorrer los nodos conviene fijar los cuatro eslabones con precisión, porque el árbol no es más que un interrogatorio ordenado sobre ellos. El primero es la escritura: alguien llamó a la operación que modifica el estado. El segundo es la detección: el sistema comparó la versión nueva con la anterior y concluyó que había cambio. El tercero es la notificación: la lista de suscriptores de ese dato recibió el aviso. El cuarto es el repintado: la vista tomó el valor nuevo y lo llevó a la pantalla. Cualquier fallo de reactividad, en cualquier librería, es la interrupción de exactamente uno de esos cuatro pasos, y por eso cuatro preguntas bastan para agotar el espacio.

El primer error metodológico es empezar mirando lo que falló. Lo que falló es el pixel, y el pixel es el final de la cadena; interrogarlo solo confirma lo que ya sabías. La raíz del árbol apunta al otro extremo: instrumenta la fuente y averigua si el dato cambió realmente. La pregunta se responde en treinta segundos con una suscripción cruda al store, una traza dentro del setter o una lectura directa desde la consola del navegador, y su valor está en que parte el bosque exactamente por la mitad.

// Nodo raiz: instrumenta la fuente, jamas el componente que no pinto.
const cancelar = store.subscribe((s) => console.log('escritura', s.total))

// Si esto no imprime nada al interactuar, el fallo es anterior a la reactividad:
// el manejador no se enlazo, una guarda corto antes, o escribiste en otra instancia.

Si la traza no aparece, has ganado el diagnóstico entero: el problema no es reactivo. Estás ante un manejador que nunca se enlazó al elemento, una guarda que retorna antes de escribir, una condición que se evalúa al revés, o —el caso más humillante y más común en aplicaciones grandes— dos instancias del mismo store viviendo a la vez porque un módulo se duplicó en el empaquetado y escribes en una mientras lees de la otra. Nada de esto se arregla tocando efectos ni memos, y toda hora invertida en el grafo reactivo antes de responder esta pregunta es una hora tirada.

El caso de las dos instancias merece una nota aparte porque desafía cualquier razonamiento y se resuelve en un segundo si sabes buscarlo. Ocurre cuando el módulo que crea el store se evalúa dos veces: por una ruta de importación distinta, por una copia duplicada en el árbol de dependencias, o por convivir una versión empaquetada y otra en desarrollo. El síntoma es que tanto la escritura como la lectura funcionan perfectamente, cada una contra su propio objeto, y ninguna instrumentación local revela nada raro. La prueba definitiva cuesta una línea: imprime la identidad del store desde el punto de escritura y desde el de lectura y comprueba con Object.is que son el mismo.

Si la traza sí aparece y la instancia es única, el sistema recibió tu escritura y el árbol se estrecha a la mitad interesante: el cambio existe, pero algo entre él y la pantalla lo perdió. Los dos nodos siguientes cubren, entre ambos, la práctica totalidad de ese territorio, y lo hacen dividiéndolo por una frontera muy nítida —la identidad del valor— que es la única pregunta que separa un fallo de detección de un fallo de suscripción.

Nodo dos: cambió el valor, pero no cambió la identidad

La pregunta de este nodo se formula con una precisión que conviene respetar: no es si el valor es distinto, sino si la referencia es distinta. Son dos preguntas diferentes y confundirlas es lo que hace que este fallo parezca inexplicable. Un array cuyo contenido cambió por completo puede seguir siendo, para el sistema, exactamente el mismo array.

Aquí vive la primera hoja densa del árbol, y su nombre es mutación en sitio. El dato es correcto —si lo imprimes, ves exactamente lo que esperabas— y aun así nadie reaccionó. La causa es que casi todo el ecosistema, desde useState hasta los selectores de un store, detecta el cambio comparando identidades con Object.is, no recorriendo estructuras en profundidad. Un push sobre un array, una asignación a una propiedad o un sort in situ modifican el contenido dejando intacta la referencia, y desde el punto de vista del sistema no ha pasado absolutamente nada.

// Mutacion en sitio: el array sigue siendo el mismo objeto.
setLista((l) => { l.push(nuevo); return l }) // misma referencia, cero notificaciones

// Correccion: una referencia nueva por cada version del estado.
setLista((l) => [...l, nuevo])

// El mismo fallo, disfrazado de escritura profunda.
setPerfil((p) => { p.direccion.ciudad = 'Madrid'; return p })
setPerfil((p) => ({ ...p, direccion: { ...p.direccion, ciudad: 'Madrid' } }))

Conviene entender esto como lo que es: no un defecto de la librería sino el precio explícito de la inmutabilidad que ya justificaste en el nivel de la comparación por referencia. La detección barata por identidad es lo que permite decidir en un ciclo de reloj si algo cambió, y a cambio exige que cada versión del estado sea un objeto nuevo. Los sistemas que sí detectan la mutación profunda —los que envuelven el estado en un Proxy, como Immer, Valtio o los stores de grano fino— no tienen magia adicional: pagan el coste de interceptar cada acceso.

La variante profunda del ejemplo anterior enseña algo que a menudo se pasa por alto: no basta con devolver un objeto nuevo en la raíz. Si copias el nivel superior pero reutilizas el objeto anidado que en realidad modificaste, cualquier selector suscrito a esa rama seguirá viendo la misma referencia y seguirá callado, aunque el componente que lee la raíz sí se repinte. De ahí la confusión clásica de que unas partes de la pantalla se actualizan y otras no ante la misma escritura: la referencia nueva llegó hasta cierta profundidad y se detuvo justo antes del dato que importaba.

⚠️
El síntoma que delata la mutación en sitio

Hay una firma inconfundible que identifica este caso sin necesidad de leer el código: el valor correcto aparece en pantalla más tarde, cuando cualquier otra cosa fuerza un repintado. Pulsas el botón y no pasa nada; abres un menú, cambias de pestaña o redimensionas la ventana, y de golpe el dato está ahí, actualizado y perfecto. Eso demuestra que la escritura sí ocurrió y que la vista sabe pintarlo bien: lo único que faltó fue la notificación. Cuando veas ese patrón de actualización diferida por accidente, salta directo a este nodo del árbol y busca dónde mutaste sin crear referencia nueva. Ninguna otra causa produce exactamente ese síntoma.

Nodo tres: hubo notificación, pero tú nunca fuiste suscriptor

Este nodo se distingue del anterior por una prueba de un segundo que conviene tener a mano. Si el valor es correcto cuando lo imprimes desde fuera —desde la consola, desde una traza en el store, desde otro componente que sí reacciona— pero el lector que te preocupa nunca lo ve cambiar, la escritura y la detección funcionaron y el fallo está en la suscripción. Que unas partes de la interfaz reaccionen mientras otras se quedan quietas ante la misma escritura es la firma exacta de este nodo, y descarta de golpe los dos anteriores.

La segunda hoja densa es la suscripción ausente, y es más sutil porque el código parece correcto: el valor se lee, se muestra, y el sistema reactivo funciona en el resto de la aplicación. Lo que falla es el momento de la lectura. En cualquier sistema de rastreo automático, la suscripción no se declara: se crea como efecto lateral de leer dentro de un contexto que está observando. Leer el mismo valor fuera de ese contexto devuelve el dato correcto de entonces y no registra a nadie, así que ese lector queda congelado en el tiempo para siempre.

// Lectura fuera del contexto de rastreo: se lee una vez y nunca se resuscribe.
const total = contador() // no hay observador activo que registrar
createEffect(() => console.log(total)) // imprime siempre el mismo numero

// Correccion: la lectura ocurre dentro del efecto, y ahi si se registra.
createEffect(() => console.log(contador()))

// La misma trampa al desestructurar un store reactivo en el montaje.
const { usuario } = estado // se extrae el valor, se pierde la reactividad

La desestructuración prematura es la versión más frecuente de este fallo y la más difícil de ver, porque desestructurar es un hábito tan arraigado que la mirada resbala sobre él. Al desestructurar extraes el valor actual y rompes el vínculo con el getter que lo hacía reactivo: a partir de ahí manejas una fotografía. La regla que lo previene es una sola y vale para signals, stores y selectores por igual: lee lo más tarde y lo más adentro posible, en el punto exacto donde el valor se usa, nunca en la periferia del componente.

ℹ️
La suscripción condicional: el caso que solo falla a veces

Existe una variante de este nodo que produce el diagnóstico más desconcertante de todos, porque el componente reacciona en unas circunstancias y no en otras. Ocurre cuando la lectura reactiva está dentro de una rama condicional: si la condición era falsa durante la primera ejecución del efecto, esa fuente jamás llegó a leerse, jamás se registró como dependencia, y el efecto no volverá a ejecutarse cuando cambie. El sistema no adivina las dependencias que podrías haber leído; solo conoce las que leíste. Por eso, en cualquier sistema de rastreo automático, el conjunto de dependencias de un efecto es el de la última ejecución, no el del código escrito, y una rama que nunca se tomó es una arista que nunca existió. Si un efecto reacciona cuando lo pruebas en un orden y no en otro, empieza buscando lecturas escondidas tras condicionales, retornos tempranos o evaluaciones perezosas.

flowchart TD
Q[no se actualiza] --> N1[cambio el dato en la fuente]
N1 -->|no| F1[fallo previo: manejador guarda o store duplicado]
N1 -->|si| N2[cambio la identidad del valor]
N2 -->|no| F2[mutacion en sitio: devuelve referencia nueva]
N2 -->|si| N3[la lectura ocurrio dentro del contexto reactivo]
N3 -->|no| F3[suscripcion ausente: lee dentro del efecto]
N3 -->|si| F4[el estado esta sano: el fallo vive en la vista]
style F1 fill:#f9e2af,color:#11111b
style F2 fill:#f38ba8,color:#11111b
style F3 fill:#f38ba8,color:#11111b
style F4 fill:#a6e3a1,color:#11111b

Nodo cuatro: el estado está sano y el culpable es la vista

Si sobreviviste a los tres nodos anteriores, el estado queda absuelto: el dato cambió, cambió su identidad y tu lector estaba suscrito. Lo que queda es una capa de vista que recibió la notificación y decidió no repintar, o repintó sobre un componente equivocado. Los tres sospechosos de este nodo son un comparador de memoización que ignora la prop que cambió, una clave de lista inestable que hace que el sistema reutilice el nodo incorrecto, y un componente no controlado que lee su valor inicial una única vez y desprecia las actualizaciones posteriores.

La distinción práctica entre este nodo y los anteriores es limpia: aquí el valor correcto está disponible en el momento del render y puedes comprobarlo imprimiéndolo justo antes de devolverlo. Si la traza dice diez y la pantalla dice ocho en el mismo instante, ya no estás depurando reactividad; estás depurando reconciliación, memoización o control de entradas, que son problemas de otra naturaleza y se resuelven con otras herramientas.

// Absolver al estado y acusar a la vista: imprime justo antes de devolver.
function Fila({ item }: Props) {
  console.log('render con', item.cantidad) // si aqui el valor es correcto...
  return <td>{item.cantidad}</td>          // ...y la pantalla miente, el fallo es de vista
}

// Sospechoso tipico: un comparador que ignora la prop que si cambio.
export default memo(Fila, (a, b) => a.item.id === b.item.id) // cantidad nunca se compara

El comparador personalizado de memoización merece una mención especial porque es el único mecanismo del ecosistema que puede desactivar la reactividad por completo con aspecto de optimización responsable. Un comparador que devuelve verdadero cuando no debería congela el componente de forma indefinida, y lo hace en silencio, sin advertencia ni error. Cuando llegues a este nodo, la primera pregunta debe ser siempre si alguien escribió un comparador a mano: la respuesta afirmativa resuelve el caso en la mayoría de las ocasiones.

El árbol no vale por sus nodos, sino por el orden en que los recorre

Es tentador leer esta lección como un catálogo de cuatro causas y guardarlo como una lista de comprobación, pero eso desperdicia lo único que de verdad transporta: el orden. Cualquiera puede aprenderse las cuatro causas en diez minutos; lo que separa a quien depura en tres minutos de quien tarda tres horas no es conocerlas, sino recorrerlas en la secuencia que maximiza la información obtenida por pregunta. El árbol está diseñado como una búsqueda binaria sobre el espacio de sospechosos, y su primer nodo —instrumentar la fuente en lugar de mirar la vista— es el que más territorio elimina precisamente porque contradice el instinto. El instinto empieza por el síntoma, que es el pixel, y avanza hacia atrás por la cadena, examinando cada eslabón en el orden en que la frustración lo presenta; el método empieza por la causa raíz posible y avanza hacia delante, y en cada paso descarta la mitad del bosque en lugar de un arbusto. Fíjate además en que los cuatro nodos no son cuatro bugs distintos: son los cuatro eslabones de la única cadena que existe en cualquier sistema reactivo, de cualquier framework y de cualquier año. Escritura, detección, notificación y repintado. Un signal de Solid, un store de Zustand, un observable de RxJS y un useState de React difieren en cómo implementan cada eslabón, pero ninguno puede saltárselo, y por eso este mismo árbol diagnostica los cuatro sin cambiar una sola pregunta. Cuando interiorizas eso dejas de depurar librerías y empiezas a depurar la cadena, que es lo que no cambia cuando cambias de herramienta. La consecuencia práctica es liberadora: no necesitas ser experto en la librería que tienes delante para encontrar su fallo. Necesitas saber dónde se rompen las cadenas y en qué orden preguntar.

⚔️ Construye tu propio árbol y recórrelo bajo cronómetro
  1. Toma un bug real de tu historial en el que algo no se actualizaba y reconstruye, por escrito, en qué nodo del árbol estaba la causa.
  2. Escribe una utilidad de tres líneas que instrumente la fuente de tu store e imprima cada escritura con marca de tiempo; déjala lista para pegar en cualquier proyecto.
  3. Provoca a propósito los tres fallos: una mutación en sitio, una desestructuración prematura y un manejador que no se enlaza, y anota qué síntoma exacto produce cada uno.
  4. Verifica el síntoma diferido de la mutación en sitio: comprueba que el valor correcto aparece cuando fuerzas un repintado por otra vía.
  5. Cronometra cuánto tardas en llegar al nodo culpable recorriendo el árbol frente a cuánto tardas mirando el componente que falló.
  6. Traduce el árbol a la librería que usas a diario, nombrando para cada nodo la herramienta concreta con la que respondes esa pregunta en menos de un minuto.