Estado duplicado: el mismo dato con dos custodios
El antipatrón raíz del que descienden todos los demás: el mismo hecho del mundo representado dos veces, en dos lugares con rutas de escritura distintas. Esta lección explica por qué la duplicación no es un problema de memoria sino de verdad, cómo nace siempre por una razón razonable —conveniencia de lectura, rendimiento percibido, comodidad de un formulario—, y por qué la divergencia es matemáticamente inevitable en cuanto existen dos escritores que no se coordinan. Recorre el síntoma que la delata en producción, el bug que solo aparece en el borde, el refactor en cuatro pasos que elige un propietario y convierte al resto en lectores derivados, y cierra distinguiendo las tres copias que sí son legítimas: el borrador, la cache y la instantánea histórica.
Este nivel no introduce ninguna primitiva nueva. Introduce algo más incómodo: el catálogo de las formas en que las primitivas que ya dominas se combinan mal, y empieza por la que está debajo de todas las demás, porque las cuatro lecciones siguientes son casos particulares suyos. Estado duplicado significa que un mismo hecho del mundo está representado dos veces en dos lugares distintos de tu programa, cada uno con su propia ruta de escritura y su propio momento de actualización. Conviene entender desde el principio que esto no es un problema de memoria ni de rendimiento: es un problema de verdad. En el instante en que un dato tiene dos custodios, tu aplicación deja de tener una respuesta a la pregunta de cuánto vale ese dato y pasa a tener dos, y la tarea de mantenerlas iguales queda repartida entre trozos de código que se escribieron en semanas distintas, por personas distintas, y que se ejecutan en momentos que nadie ha coordinado. La divergencia no es un riesgo: es el destino garantizado del sistema, y lo único que está en discusión es cuánto tardará en llegar y quién estará mirando cuando llegue.
- Definir la duplicación como una violación de la unicidad de la verdad, y no como un derroche de memoria.
- Reconocer las tres puertas por las que entra: conveniencia de lectura, miedo al coste y comodidad del formulario.
- Ejecutar el refactor de cuatro pasos que elige propietario y convierte a los demás en lectores derivados.
- Distinguir las copias legítimas —borrador, cache e instantánea— de las que siempre acaban divergiendo.
Dos escritores, cero coordinación
El síntoma estructural: dos rutas de escritura sobre el mismo hecho que nunca se ejecutan juntas ni se enteran la una de la otra.
El bug del borde
La divergencia no aparece en el camino feliz. Aparece en el error, en la cancelación, en el reintento y en la carrera.
Un propietario, muchos lectores
La cura no es sincronizar mejor: es eliminar la segunda copia y sustituirla por una lectura derivada del propietario.
Anatomía: por qué la copia siempre nace por una buena razón
Nadie duplica estado por descuido. La duplicación se introduce siempre con una justificación local perfectamente sensata, y ese es precisamente el motivo de que sea tan difícil de erradicar: cada instancia parece defendible por separado y solo el conjunto resulta indefendible. La primera puerta es la conveniencia de lectura. Un componente necesita el nombre del usuario, la ruta hasta el nombre pasa por tres niveles de anidamiento, y alguien decide guardar el nombre también en el store del panel para leerlo cómodo. La segunda puerta es el miedo al coste: alguien mide que recorrer una lista de mil elementos para saber cuántos están seleccionados cuesta medio milisegundo, y guarda el recuento en una variable de estado para no repetirlo. La tercera puerta es la comodidad del formulario: llega un objeto desde el servidor y el formulario copia sus campos al estado local para poder editarlos.
En los tres casos el razonamiento es correcto en el instante en que se escribe y falso en todos los instantes posteriores, porque lo que la copia introduce no es una variable de más sino una obligación de más: la de actualizar dos sitios cada vez que el hecho cambie. Esa obligación no está escrita en ninguna parte, no la comprueba el compilador y no la verifica ninguna prueba. Vive únicamente en la cabeza de quien escribió la copia, y esa cabeza olvida, se va del equipo, o simplemente no está presente el día en que otra persona añade la tercera ruta de escritura.
// Duplicacion: el mismo hecho custodiado por dos escritores independientes.
const [items, setItems] = useState<Item[]>([])
const [seleccionados, setSeleccionados] = useState<number>(0)
function alternar(id: string) {
setItems(previos => previos.map(i => (i.id === id ? { ...i, marcado: !i.marcado } : i)))
setSeleccionados(n => n + 1) // mentira en cuanto se desmarque algo
}
function recargarDesdeServidor(nuevos: Item[]) {
setItems(nuevos) // esta ruta olvida por completo el recuento
}
Fíjate en la asimetría del ejemplo: hay dos rutas que escriben items y solo una que escribe seleccionados. Ese desequilibrio es la firma exacta de la duplicación y se puede buscar mecánicamente en cualquier base de código. Cuenta cuántos sitios escriben cada variable de estado; si dos variables representan el mismo hecho y tienen un número distinto de escritores, la divergencia ya existe y solo falta que alguien navegue hasta ella.
Merece la pena precisar qué cuenta como duplicación y qué no, porque la definición ingenua produce falsos positivos que desacreditan el análisis. No es duplicación que el mismo valor aparezca en varios sitios de la memoria: eso ocurre continuamente y es inofensivo. Lo que define el antipatrón es que existan dos rutas de escritura independientes sobre representaciones del mismo hecho. Si un valor llega a diez componentes por propagación desde un único propietario, hay diez referencias y un solo custodio, y no puede haber divergencia porque no hay nadie más que pueda escribirlo. La pregunta diagnóstica no es cuántas copias veo, sino cuántas plumas pueden modificar el hecho sin consultar a las demás.
Esa precisión tiene una consecuencia práctica importante para el refactor: la solución nunca consiste en reducir el número de lugares donde el valor se lee, sino en reducir a uno el número de lugares donde se escribe. Optimizar lo primero es una obsesión frecuente y estéril que produce arquitecturas retorcidas sin tocar la causa del problema, mientras que lo segundo se consigue casi siempre borrando código.
El síntoma: la divergencia siempre llega por el borde
El rasgo más característico de este antipatrón es que el camino feliz nunca lo revela. Si el usuario abre la pantalla, hace lo que el desarrollador imaginó y cierra, las dos copias coinciden, porque el desarrollador probó exactamente ese recorrido mientras escribía la sincronización. La divergencia vive en los bordes, que son justamente los caminos que nadie prueba a mano: el fallo de red que aborta la mitad de una secuencia de escrituras, la cancelación que deshace una copia pero no la otra, el reintento que aplica dos veces un incremento, la carrera entre dos respuestas que llegan desordenadas y dejan a un custodio con la primera y al otro con la segunda.
Hay una pregunta de diagnóstico que ahorra horas y casi nadie hace la primera. Cuando alguien reporta que un contador no cuadra, que un badge muestra un número que la lista no confirma o que al volver atrás aparece un valor viejo, la reacción natural es abrir el sitio donde se muestra el número y buscar el fallo de cálculo. Es el sitio equivocado. La pregunta correcta es cuántos lugares del programa pueden escribir ese hecho. Si la respuesta es uno, tienes un error de lógica y lo encontrarás leyendo esa función. Si la respuesta es dos o más, no tienes un error de lógica: tienes un problema de diseño, y lo confirmarás en treinta segundos buscando todas las escrituras en lugar de leer una sola. La diferencia importa porque el arreglo también es distinto. Un error de lógica se corrige en la función; una duplicación no se corrige, se elimina, y cualquier intento de corregirla añadiendo otra sincronización te deja exactamente donde estabas pero con más código que mantener.
flowchart TD S[hecho del mundo: lista de tareas marcadas] --> A[copia A: el array de items] S --> B[copia B: el contador de marcadas] A --> E1[escritura por accion del usuario] A --> E2[escritura por respuesta de la red] B --> E3[escritura solo por accion del usuario] E2 --> D[divergencia: nadie actualizo la copia B] E3 --> D D --> R[refactor: elegir propietario y derivar] R --> U[una fuente y lecturas derivadas] style D fill:#f38ba8,color:#11111b style U fill:#a6e3a1,color:#11111b
Conviene tener presente el caso concreto que más sesiones de depuración consume, porque combina las dos condiciones que hacen invisible un fallo: es raro y es asíncrono. Dos peticiones parten hacia el servidor con un intervalo de doscientos milisegundos, la segunda responde antes que la primera y ambas escriben sobre el mismo par de custodios. La primera copia acaba con el valor de la petición tardía y la segunda con el de la temprana, o al revés, según qué manejador tocó cada una. Ningún test lo reproduce porque en un entorno de pruebas las respuestas llegan ordenadas y con latencias uniformes; ningún usuario lo describe con precisión porque cuando vuelve a mirar ya se ha corregido solo. La única forma de encontrarlo es dejar de buscar el fallo y contar los custodios.
// La carrera que solo ocurre en produccion: dos custodios, dos respuestas desordenadas.
async function cargarPagina(pagina: number) {
const respuesta = await pedirItems(pagina)
setItems(respuesta.items) // custodio uno
setTotalDisponible(respuesta.total) // custodio dos
// Si dos llamadas se solapan, cada custodio puede quedarse con una respuesta distinta.
}
Hay un segundo síntoma menos visible pero más caro a largo plazo: el crecimiento cuadrático del coste de añadir funcionalidad. Con una sola copia, añadir una ruta de escritura nueva cuesta escribir esa ruta. Con dos copias cuesta escribir la ruta y acordarse de la otra copia. Con tres copias cuesta la ruta más dos recuerdos, y el número de pares que hay que mantener coherentes crece con el cuadrado de las copias. Ese es el mecanismo exacto por el que una base de código se vuelve lenta de modificar sin que ningún archivo concreto parezca culpable: el coste no está en el código, está en las obligaciones implícitas que lo rodean.
El refactor: elegir propietario y derivar el resto
El refactor tiene cuatro pasos y el primero es el único que exige juicio. Paso uno: nombra el hecho en una frase del dominio, no del código. No es el contador ni el array, es cuántas tareas están marcadas. Paso dos: identifica al propietario, que es la representación de la que se pueden deducir todas las demás y no al revés. Del array se deduce el recuento; del recuento no se deduce el array, luego el array es el propietario y el recuento es un derivado disfrazado de estado. Paso tres: borra la copia y sustitúyela por una lectura derivada en el punto de consumo. Paso cuatro, el que garantiza que no vuelva: elimina el modificador de la copia para que nadie pueda reintroducirla sin borrar código a propósito.
// Refactor: el array es el propietario, el recuento es una lectura.
const [items, setItems] = useState<Item[]>([])
const seleccionados = items.filter(i => i.marcado).length // ya no puede mentir
function alternar(id: string) {
setItems(previos => previos.map(i => (i.id === id ? { ...i, marcado: !i.marcado } : i)))
}
function recargarDesdeServidor(nuevos: Item[]) {
setItems(nuevos) // ninguna ruta puede olvidarse de nada
}
El cuarto paso es el que la mayoría de los equipos omite y el que decide si el refactor dura. Mientras el modificador de la copia siga exportado, alguien volverá a llamarlo, no por descuido sino porque el autocompletado se lo ofrecerá y parecerá lo correcto. Borrar el modificador convierte la reintroducción del antipatrón en un acto deliberado que exige escribir código nuevo, y esa diferencia entre lo que se hace por inercia y lo que exige intención es, en la práctica, toda la diferencia entre un refactor que se mantiene y uno que se deshace en dos trimestres.
Hay un beneficio colateral que conviene anticipar porque suele sorprender: al eliminar el segundo custodio desaparecen también las pruebas que verificaban la sincronización entre ambos. Esas pruebas eran caras, frágiles y numerosas, y su desaparición no reduce la cobertura real del sistema, porque estaban comprobando una propiedad que ahora se cumple por construcción. Un buen indicador de que un refactor de esta familia ha ido bien es que el recuento de líneas baje tanto en el código de producción como en el de pruebas.
Cuando el propietario no es obvio porque las dos representaciones parecen igual de fundamentales, la pregunta que desempata es cuál de las dos puede reconstruirse sin pérdida de información a partir de la otra. Si ninguna puede, es que no eran el mismo hecho y no había duplicación, sino dos hechos distintos que casualmente coincidían en el camino feliz. Ese descubrimiento es valioso por sí solo, porque lo que hace falta entonces no es borrar una copia sino nombrar correctamente las dos.
Ante dos variables sospechosas, pregúntate qué pasaría si borrases una de las dos y tuvieses que reconstruirla en el acto. Si puedes escribir esa reconstrucción como una expresión pura de la otra, la variable que ibas a borrar no era estado: era una expresión guardada, y el refactor es sustituirla por la expresión. Si necesitas información que no está en la otra variable, entonces sí es estado independiente y tu problema es de nomenclatura, no de duplicación.
Las tres copias legítimas: borrador, cache e instantánea
Sería un error salir de esta lección pensando que toda copia es un pecado, porque hay tres casos en los que la segunda representación es correcta y necesaria, y confundirlos con el antipatrón lleva a refactores que rompen funcionalidad real. El primero es el borrador. Cuando un usuario edita un formulario, el valor que está escribiendo no es todavía el hecho del mundo: es una propuesta de hecho que puede cancelarse. Original y borrador no son dos copias del mismo dato, son dos datos distintos con nombres distintos y ciclos de vida distintos, y precisamente por eso pueden y deben diferir. La señal de que lo has hecho bien es que existe un momento explícito de confirmación y otro de descarte.
El segundo caso legítimo es la cache. Una cache es una copia deliberada de un dato remoto que asume la divergencia como parte de su contrato en lugar de negarla, y por eso incluye siempre los tres mecanismos que la hacen honesta: una política de caducidad, una vía de invalidación y una noción de dato viejo pero utilizable. Lo que distingue una cache de una duplicación no es la existencia de la copia, sino que la cache sabe que puede estar desactualizada y tiene un plan para ello, mientras que la duplicación cree que nunca lo estará.
// Copia legitima: original y borrador son dos hechos distintos con nombres distintos.
const [guardado, setGuardado] = useState<Perfil>(perfilRemoto)
const [borrador, setBorrador] = useState<Perfil>(perfilRemoto)
const hayCambios = borrador !== guardado
function confirmar() { setGuardado(borrador) } // momento explicito de aceptacion
function descartar() { setBorrador(guardado) } // momento explicito de renuncia
El tercer caso es la instantánea histórica. Cuando guardas el precio con el que se cerró un pedido no estás duplicando el precio del producto: estás registrando un hecho distinto, el precio en aquel momento, que debe permanecer inmutable aunque el catálogo cambie mañana. Aquí la copia es la única forma correcta de modelar el dominio, y derivarla del propietario actual sería el bug, no la solución.
El criterio que unifica los tres casos y permite decidir en el momento es el de la divergencia esperada. Pregúntate qué debería ocurrir si las dos representaciones dejaran de coincidir. Si la respuesta es que eso sería un fallo que hay que corregir, tienes una duplicación y debes eliminar una de las dos. Si la respuesta es que eso es precisamente lo que la funcionalidad significa —el usuario tiene cambios sin guardar, la cache está caducada, el precio del catálogo subió después de la venta— entonces no son dos copias de un hecho, son dos hechos distintos y su diferencia es información valiosa. La prueba está en si puedes nombrarlos con dos sustantivos distintos del dominio sin forzar el lenguaje; cuando el único nombre disponible para ambos es el mismo, la duplicación es real.
Conviene entender por qué este antipatrón encabeza el nivel y no aparece como uno más de la lista. Todo lo que llamamos razonar sobre un programa consiste en poder responder preguntas sobre su estado sin ejecutarlo, y esa capacidad descansa en un supuesto que damos por sentado hasta que lo perdemos: que cada pregunta tiene una respuesta. Cuando un hecho tiene dos custodios, la pregunta de cuánto vale ese hecho deja de tener respuesta y pasa a tener dos respuestas potencialmente distintas cuya diferencia depende del orden temporal en que se ejecutaron dos rutas de código que nadie escribió pensando la una en la otra. En ese momento tu programa ya no es un objeto sobre el que se razona, es un objeto sobre el que se apuesta. Y observa que ninguna cantidad de disciplina resuelve esto, porque el problema no es que alguien olvide sincronizar: es que la sincronización correcta requiere conocimiento global —saber todas las rutas que escriben el hecho— en un sistema diseñado precisamente para que cada módulo solo conozca lo suyo. Estás pidiendo información global a un diseño local, y esa contradicción no se arregla con más cuidado, se arregla eliminando la exigencia. Por eso la fuente única de verdad no es una regla de estilo que se pueda relajar cuando hay prisa, sino la precondición que hace que exista algo llamado el estado de la aplicación en lugar de una colección de variables que a veces coinciden. Y por eso, cuando en las lecciones siguientes veas un derivado guardado, un efecto que copia una prop o un store convertido en vertedero, reconocerás la misma criatura con tres disfraces: en los tres casos alguien creó un segundo custodio para un hecho que solo debía tener uno.
- Elige una pantalla y escribe en una hoja todos los hechos del dominio que muestra, en frases del negocio y no del código.
- Para cada hecho, cuenta cuántas variables de estado lo representan y cuántas rutas de escritura tiene cada una.
- Marca como sospechoso todo hecho con más de una representación y con un número distinto de escritores por representación.
- Aplica la prueba del propietario a cada sospechoso y decide cuál de las dos representaciones puede reconstruirse desde la otra.
- Elimina una copia real, borrando también su modificador para que nadie pueda reintroducirla sin darse cuenta.
- Clasifica las copias que decidas conservar como borrador, cache o instantánea, y escribe al lado la razón por la que su divergencia es correcta.