wandres.dev
ANTI-PATRONES · síntomas y refactor

El catálogo de refactors: síntoma, diagnóstico y remedio

Cierre del nivel en forma de instrumento de trabajo: diez errores de estado ordenados por la familia a la que pertenecen, cada uno con el síntoma que se observa en producción, el diagnóstico que identifica el invariante roto y el remedio concreto que lo elimina en lugar de administrarlo. La lección explica primero por qué el diagnóstico debe hacerse por invariante y no por parecido superficial, agrupa después los diez casos en tres familias —la verdad repartida, la derivación congelada y el dato en el sitio equivocado— y termina convirtiendo el catálogo en una rutina de revisión de código que evita que los mismos errores vuelvan a entrar.

⏱ 20 min

Las cuatro lecciones anteriores diseccionaron cuatro patologías con detenimiento; esta las convierte en herramienta. Un catálogo de refactors no es una lista de cosas que no hay que hacer, porque las listas de prohibiciones no se recuerdan cuando hacen falta y no se aplican bajo presión. Un catálogo útil se organiza al revés, por lo que uno observa antes de saber qué está mirando: el síntoma. Alguien reporta que un número no cuadra, que un formulario muestra datos del registro anterior, que un enlace compartido no reproduce la pantalla, que al abrir un diálogo aparece lo del anterior. Ese es el punto de entrada real, y el trabajo del catálogo consiste en llevarte desde ahí hasta el invariante roto y desde el invariante hasta el remedio, sin pasar por la fase intermedia en la que uno improvisa un parche que funciona hoy y añade una obligación más al sistema. Los diez casos que siguen cubren, en mi experiencia y en la de cualquiera que haya auditado unas cuantas aplicaciones, la inmensa mayoría de lo que se encuentra: no porque no existan errores más exóticos, sino porque los exóticos son casi siempre uno de estos diez con un disfraz local.

🎯 Al terminar esta lección sabrás
  • Diagnosticar por invariante roto en lugar de por parecido superficial con un caso conocido.
  • Recorrer los diez errores más frecuentes con su síntoma observable, su causa y su remedio concreto.
  • Reconocer las tres familias a las que pertenecen y por qué cada familia comparte un mismo tipo de remedio.
  • Convertir el catálogo en una rutina de revisión que impida la reentrada de los mismos errores.
🚨

Se entra por el síntoma

Nadie llega diciendo que tiene un derivado materializado. Llega diciendo que un número no cuadra. El catálogo empieza ahí.

🔬

Se diagnostica por invariante

Dos bugs con el mismo aspecto pueden tener causas opuestas. Lo que decide el remedio es qué regla se ha roto, no a qué se parece.

🧹

Se remedia eliminando

Un buen remedio quita la posibilidad del error. Si tu arreglo añade una obligación de recordar algo, no has arreglado nada.

Cómo se usa: del síntoma al invariante, y solo después al remedio

El error metodológico que más tiempo hace perder es saltar del síntoma al remedio por parecido. Dos fallos pueden manifestarse de forma idéntica —un valor que no se actualiza— y proceder de causas opuestas: en un caso hay una copia que nadie sincroniza y sobra un custodio, en otro hay una derivación que se evalúa con una dependencia que falta y falta una arista. Si aplicas al segundo el remedio del primero acabarás borrando un estado necesario, y si aplicas al primero el del segundo añadirás una sincronización más a un sistema que ya tenía demasiadas.

Ese error tiene una versión organizativa aún más cara. Cuando un equipo acumula parches por parecido durante un par de años, el código se llena de defensas locales que se contradicen entre sí: una función que comprueba antes de escribir porque una vez hubo una carrera, otra que reescribe al montar porque una vez llegó un valor viejo, una tercera que limpia al desmontar porque una vez quedó residuo. Ninguna de las tres documenta el invariante que protege, así que nadie puede retirarlas sin miedo, y el sistema queda cubierto por una capa de rituales cuya función original ya no consta en ninguna parte.

El paso intermedio que evita ambas cosas es nombrar el invariante roto en una frase. Un invariante es una afirmación que debería ser verdadera en todo instante observable del programa: el total es siempre la suma de las líneas, el formulario muestra siempre el registro seleccionado, la URL describe siempre lo que se ve. Cuando puedes escribir la frase, el diagnóstico es inmediato, porque solo hay tres maneras de romper un invariante de estado. O el hecho tiene dos custodios y uno se quedó atrás, que es la familia de la verdad repartida. O la relación entre el hecho y su derivado se sustituyó por una fotografía, que es la familia de la derivación congelada. O el dato está guardado en un ámbito cuyo ciclo de vida no coincide con el del hecho que representa, que es la familia de la ubicación errónea.

flowchart LR
S[sintoma observado en produccion] --> I[nombrar el invariante roto]
I --> F1[familia uno: verdad repartida]
I --> F2[familia dos: derivacion congelada]
I --> F3[familia tres: ubicacion erronea]
F1 --> R1[remedio: eliminar el segundo custodio]
F2 --> R2[remedio: restaurar la relacion en la lectura]
F3 --> R3[remedio: mover el dato a su ambito]
style S fill:#f9e2af,color:#11111b
style I fill:#89b4fa,color:#11111b

Las tres familias no son una taxonomía cómoda inventada para ordenar la lista, sino las tres únicas formas que existen de romper una regla sobre el estado, y por eso la clasificación es exhaustiva. Un invariante relaciona un hecho con su representación; puede fallar porque haya más de una representación con autoridad, porque la representación se haya desconectado del hecho, o porque la representación viva en un contenedor cuya duración no corresponde a la del hecho. No hay una cuarta manera, y esa exhaustividad es lo que convierte al catálogo en una herramienta de diagnóstico y no en una colección de anécdotas: si tu bug es de estado, pertenece a una de las tres, y averiguar a cuál es la mitad del trabajo.

Familia uno: la verdad repartida

1. Dos custodios para un mismo hecho. Síntoma: un contador, un badge o un resumen que contradice a la lista que tiene al lado, y solo a veces. Diagnóstico: dos variables representan el mismo hecho y tienen un número distinto de rutas de escritura; la que tiene menos se queda atrás en los caminos de error, cancelación y reintento. Remedio: elige como propietaria la representación desde la que se puede reconstruir la otra, borra la segunda junto con su modificador y sustitúyela por una lectura derivada.

2. El efecto que copia una prop al estado local. Síntoma: un fotograma con datos del elemento anterior al cambiar de selección, y ocasionalmente la pérdida de lo que el usuario acababa de escribir. Diagnóstico: el efecto se ejecuta después del pintado, de modo que existe un render con la prop nueva y el estado viejo, y además hay dos escritores compitiendo por el mismo campo. Remedio: deriva si solo se lee transformado, cambia la identidad del componente si hay que reiniciar, ajusta durante el render si solo hay que corregir una parte y eleva el control si el usuario debe editarlo.

3. La prop usada como valor inicial que nunca se actualiza. Síntoma: el panel de detalle conserva los datos del primer elemento seleccionado por muchos que se elijan después. Diagnóstico: no es un fallo, es la semántica documentada del valor inicial, que solo se aplica en el primer render de esa instancia y el árbol se está reutilizando. Remedio: dar identidad distinta al componente por registro para forzar el remontaje, o elevar el hecho al padre; añadir un efecto sincronizador es la única respuesta que no resuelve la pregunta.

// Error uno, forma canonica: dos representaciones con distinto numero de escritores.
const [items, setItems] = useState<Item[]>([])
const [pendientes, setPendientes] = useState(0)

// Remedio: la coleccion es la propietaria y el recuento pasa a ser una lectura.
const pendientes = items.filter(i => !i.completado).length

4. La respuesta obsoleta que gana la carrera. Síntoma: al teclear rápido en un buscador aparecen durante un instante los resultados de una consulta anterior, o el detalle de un elemento que ya no está seleccionado. Diagnóstico: varias peticiones en vuelo escriben sobre el mismo estado sin comprobar si siguen siendo pertinentes, y el orden de llegada no coincide con el de salida. Remedio: cancelar la petición anterior con una señal de aborto, o sellar cada respuesta con la clave que la solicitó y descartar la que no corresponda a la clave vigente; en la práctica, delegar en una capa de cache que ya lo hace por clave.

⚠️
Si el arreglo consiste en añadir otra sincronización, has cambiado el bug de sitio

Hay una prueba que separa los remedios verdaderos de los parches y conviene aplicarla antes de abrir el editor. Pregúntate si tu arreglo reduce el número de lugares que pueden escribir el hecho o lo aumenta. Un remedio de esta familia siempre lo reduce: elimina un custodio, quita una escritura, hace imposible la divergencia. Un parche siempre lo aumenta: añade una llamada más al modificador, un efecto más que vigila, una comprobación más que compara antes de escribir. La señal de alarma más clara es un comentario en el código explicando que hay que acordarse de actualizar también otra cosa; ese comentario es la confesión de que el invariante ya no lo sostiene el programa sino la memoria de quien lo escribió, y esa memoria caduca en la siguiente rotación del equipo.

Familia dos: la derivación congelada

Antes de pasar a la familia siguiente conviene retener el rasgo que la define entera, porque es lo que permite reconocerla sin recorrer los cuatro casos. En la familia de la verdad repartida, el fallo se reproduce con dificultad y depende del orden temporal de dos sucesos que el programa no coordina; por eso sus bugs se etiquetan casi siempre como intermitentes y sobreviven en el sistema de incidencias durante meses con la nota de que no se ha podido reproducir. Cuando leas esa nota en un ticket de estado, empieza por contar custodios: en la mayoría de los casos el diagnóstico está ahí y no hace falta reproducir nada.

5. El derivado guardado en una variable de estado. Síntoma: un total, un recuento o una validez que se desajusta tras ciertas operaciones y no tras otras. Diagnóstico: alguien sustituyó una relación permanente por una fotografía tomada una vez, y la obligación de refrescarla quedó repartida entre las funciones de mutación. Remedio: evaluar la expresión en el punto de lectura; si una medición real lo justifica, memoizar con las dependencias correctas, que conserva la corrección; materializar solo con invalidación explícita y prueba de coherencia.

6. El derivado mantenido con un efecto. Síntoma: un valor correcto que aparece parpadeando, precedido durante un fotograma por el valor anterior, y un render de más en el perfilador. Diagnóstico: el derivado sí se recalcula, pero después del pintado, de modo que la coherencia se restaura tarde en lugar de mantenerse siempre. Remedio: eliminar el efecto y la variable, y devolver la relación al render; el ahorro secundario es que desaparece la mitad del código.

7. Los booleanos que codifican una máquina de estados. Síntoma: pantallas que muestran a la vez un indicador de carga y un mensaje de error, o un vacío cuando ya hay datos. Diagnóstico: tres o cuatro banderas independientes describen lo que en realidad es un único estado con transiciones legales, y su producto cartesiano contiene combinaciones que el dominio no admite pero el tipo sí. Remedio: sustituir las banderas por una unión discriminada con un campo de etiqueta, de modo que los estados imposibles dejen de ser representables y el compilador exija tratar cada caso.

8. La posición usada como identidad. Síntoma: al ordenar, filtrar o borrar un elemento, la selección salta a otra fila o se pierde. Diagnóstico: el estado guarda el índice dentro de una colección en lugar de la identidad del elemento, y el índice es un derivado de un orden que cambia. Remedio: guardar la identidad estable y derivar la posición cuando haga falta, que es la dirección correcta de la dependencia.

// Error siete: cuatro banderas admiten dieciseis combinaciones y solo cuatro son legales.
const [cargando, setCargando] = useState(false)
const [error, setError] = useState<string | null>(null)
const [datos, setDatos] = useState<Pedido[] | null>(null)

// Remedio: una union discriminada. Lo imposible deja de ser representable.
type Peticion =
  | { estado: 'inactiva' }
  | { estado: 'cargando' }
  | { estado: 'error'; mensaje: string }
  | { estado: 'lista'; datos: Pedido[] }

Los cuatro casos de esta familia comparten un rasgo que conviene fijar porque orienta el remedio: en todos ellos el valor guardado fue correcto en algún momento del pasado. Eso los hace especialmente resistentes a la depuración por inspección, porque cuando abres las herramientas y miras el estado, lo que ves es plausible; no hay ningún valor absurdo que llame la atención, solo un valor que fue verdadero hace tres acciones. La técnica que los delata no es mirar el valor sino compararlo con el resultado de recalcularlo en ese instante, y por eso conviene exponer en desarrollo una función que haga esa comparación para los derivados sospechosos.

Familia tres: la ubicación errónea

9. El estado de servidor dentro del store global. Síntoma: datos viejos tras una mutación, listas que no se refrescan al volver a una pantalla y una cantidad desproporcionada de acciones, reductores y banderas de carga escritas a mano. Diagnóstico: se está reimplementando una cache sin caducidad, sin invalidación, sin deduplicación de peticiones y sin noción de dato viejo pero utilizable. Remedio: mover el recurso a una capa de cache de servidor, con su clave y su invalidación tras cada mutación; el borrado de código que acompaña a este cambio suele ser el argumento más persuasivo del catálogo.

10. El estado navegable guardado en memoria. Síntoma: un enlace compartido no reproduce lo que el remitente veía, el botón de atrás no deshace un filtro y recargar la página devuelve al usuario a la primera pestaña. Diagnóstico: filtros, orden, paginación, término de búsqueda y pestaña activa describen la vista y por tanto pertenecen a la dirección, pero se han guardado en un ámbito que muere con el proceso. Remedio: elevar esas piezas a los parámetros de la dirección y leerlas desde ahí, de modo que el navegador aporte gratis la persistencia, el historial y la compartibilidad.

// Error nueve, antes: una cache manuscrita con la mitad de las piezas que hacen falta.
const pedidos = useSelector(s => s.pedidos)
const cargando = useSelector(s => s.cargandoPedidos)
useEffect(() => { dispatch(traerPedidos()) }, [dispatch, filtro])

// Despues: la capa que ya resuelve clave, caducidad, deduplicacion e invalidacion.
const { data: pedidos, isPending } = useQuery({
  queryKey: ['pedidos', filtro],
  queryFn: () => traerPedidos(filtro),
})

A estos dos casos conviene añadir su reverso, que aparece con la misma frecuencia y suele pasar inadvertido porque no produce bugs ruidosos sino lentitud difusa: el dato puramente local que vive arriba. Un desplegable abierto, el paso de un asistente, el texto de un borrador o la fila sobre la que está el cursor no tienen ningún consumidor fuera de su subárbol, y colocarlos en el store cobra los cuatro impuestos de la lección anterior sin ninguna contrapartida. La regla que resuelve las tres situaciones es la misma en las tres direcciones: el estado vive en el ámbito más pequeño que contiene a todos sus consumidores.

Esta familia tiene una virtud que conviene aprovechar al planificar el trabajo: sus remedios son los que más código eliminan y los que producen resultados más fáciles de defender ante quien no participa en la decisión técnica. Migrar un recurso de servidor fuera del store borra acciones, reductores, tipos y banderas; llevar un filtro a la dirección arregla un problema de producto que los usuarios ya habían reportado. Si necesitas construir credibilidad para una campaña de saneamiento más amplia, empieza por aquí y deja las dos primeras familias para cuando el argumento ya esté ganado.

ℹ️
Los diez casos no aparecen con la misma frecuencia y conviene saber cuáles buscar primero

Si vas a auditar una aplicación que no conoces, el orden de búsqueda no es indiferente y hay uno que rinde mucho más que los demás. Empieza por el caso nueve, el estado de servidor dentro del store, porque es el que más volumen ocupa en casi todas las bases de código y el que más código elimina al corregirse; localizarlo es trivial, basta buscar banderas de carga escritas a mano. Sigue por el diez, el estado navegable en memoria, que se detecta en dos minutos copiando una dirección tras aplicar un filtro y abriéndola en otra pestaña. Continúa con el dos y el tres, que se encuentran buscando efectos cuyo cuerpo sea una sola llamada a un modificador. Deja para el final los casos uno y cuatro, que son los más dañinos pero también los que exigen leer con atención, y que resultan mucho más fáciles de identificar cuando los otros seis ya han salido del camino y el ruido de fondo ha bajado.

💡
Convierte el catálogo en tres preguntas de revisión de código

El catálogo rinde de verdad cuando deja de consultarse tras el bug y empieza a aplicarse antes. Tres preguntas bastan para cubrir las tres familias y caben en cualquier revisión sin alargarla. Primera: ¿este cambio añade una segunda representación de un hecho que ya existe en otro sitio? Segunda: ¿alguna de las variables de estado que introduce podría escribirse como una expresión de las demás? Tercera: ¿el ámbito elegido para cada dato coincide con el conjunto de sus consumidores, ni más grande ni más pequeño? Un cambio que responde no, no y sí a esas tres preguntas es incapaz de contener ninguno de los diez errores del catálogo, y formularlas cuesta menos que diagnosticar uno solo de ellos en producción seis meses después.

Los diez errores son un solo error: intentar que dos cosas coincidan en vez de hacer que sean una

Al llegar al final del catálogo merece la pena mirar los diez casos juntos y preguntarse qué comparten, porque la respuesta reorganiza todo el nivel en una sola idea. En los diez, sin excepción, alguien creó una situación en la que dos cosas debían coincidir y confió esa coincidencia a que ocurriera algo: una llamada que alguien recordara hacer, un efecto que se ejecutara a tiempo, un orden de respuestas que llegara como salió, una limpieza que se disparara al desmontar. Y en los diez el remedio tiene la misma forma: eliminar la necesidad de coincidencia haciendo que solo exista una cosa. El derivado no se recalcula mejor, se deja de guardar. La copia no se sincroniza mejor, se borra. La cache no se reimplementa mejor, se delega. El filtro no se persiste mejor, se coloca donde la persistencia ya existía. Esta es exactamente la distinción entre corrección por construcción y corrección por mantenimiento, y es una de las pocas ideas de la ingeniería de software que se sostiene igual en cualquier lenguaje, cualquier framework y cualquier década. Un sistema correcto por construcción no puede entrar en el estado malo porque ese estado no es representable; un sistema correcto por mantenimiento puede entrar en él y depende de que alguien, en cada punto del código, haga lo debido. El segundo funciona perfectamente el día que se escribe y falla de forma estadística durante los años siguientes, cuando el número de puntos que deben acertar crece y la memoria colectiva del equipo se renueva. Por eso la pregunta que conviene llevarse de este nivel entero no es cuál de los diez errores tengo, sino una más incómoda y más productiva: cuántas coincidencias está sosteniendo hoy mi aplicación por disciplina en lugar de por construcción. Cada una de esas coincidencias es un bug que aún no se ha manifestado, con una probabilidad de manifestarse que no baja con el tiempo, y el trabajo de arquitectura del estado consiste, casi por entero, en ir convirtiéndolas en identidades hasta que no quede ninguna que dependa de que alguien se acuerde.

⚔️ Aplica el catálogo a tu aplicación real
  1. Reúne los diez últimos bugs de estado de tu historial y clasifícalos en las tres familias, sin mirar todavía el remedio que se aplicó.
  2. Escribe para cada uno el invariante roto en una sola frase del dominio, y comprueba cuántos remedios pasados atacaban el síntoma en vez de la causa.
  3. Localiza en tu código un caso de cada familia y estima cuántas líneas desaparecerían al aplicar el remedio del catálogo.
  4. Ejecuta el remedio del caso de la familia dos, que suele ser el más barato, y mide los renders que se eliminan.
  5. Migra un recurso completo de servidor fuera del store global y contabiliza el código borrado frente al añadido.
  6. Sustituye un conjunto de banderas booleanas por una unión discriminada y comprueba qué combinaciones imposibles permitía el tipo anterior.
  7. Incorpora las tres preguntas de revisión a tu plantilla de cambios y aplícalas durante un mes antes de juzgar si merecen quedarse.