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

Todo en global: el store que se convierte en vertedero

El antipatrón que no se comete nunca y sin embargo está en todas partes: el store global al que nadie decidió meterlo todo y en el que, sin embargo, ha terminado todo. Esta lección explica la física del vertedero —por qué el coste marginal de añadir una pieza es siempre cero y el coste acumulado siempre insoportable—, describe los cuatro impuestos que se pagan por ello y propone un instrumento concreto para revertirlo: el triaje de tres preguntas que devuelve cada dato a su clase. Incluye la delimitación de lo que sí es estado global legítimo, un procedimiento de vaciado incremental que no requiere parar el desarrollo, y la defensa estructural que impide que el vertedero se vuelva a llenar.

⏱ 17 min

Este antipatrón tiene una propiedad que lo distingue de los tres anteriores y que explica por qué es tan difícil de combatir: nadie lo comete. No existe la reunión en la que un equipo decide meter absolutamente todo el estado en el store global; existen, en cambio, doscientas decisiones individuales, tomadas en doscientos días distintos, cada una de las cuales era razonable por separado. Alguien necesitaba que dos pantallas lejanas compartieran un dato y lo puso arriba. Alguien más venía de una arquitectura donde el store era el sitio natural de la verdad y siguió su costumbre. Un tercero tenía prisa, vio que el store ya estaba montado y le pareció el camino de menor resistencia. Ninguno hizo nada indefendible, y el resultado conjunto es un árbol de estado que no cabe en la cabeza de nadie, donde la apariencia efímera de un desplegable convive con la sesión del usuario, con una lista traída de la red y con el borrador de un formulario. La lección no va, por tanto, de convencerte de que no lo hagas: va de entender la física que hace inevitable la acumulación y de darte el instrumento para revertirla, porque contra una fuerza estructural la buena intención no sirve de nada.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la acumulación es estructural: coste marginal cero, coste acumulado creciente y ninguna señal intermedia.
  • Enumerar los cuatro impuestos que cobra el vertedero: acoplamiento, ruido reactivo, ciclo de vida equivocado y pérdida de propiedad.
  • Aplicar el triaje de tres preguntas que devuelve cada dato a la clase de estado que le corresponde.
  • Ejecutar un vaciado incremental y montar la defensa que impide que el store se vuelva a llenar.
🗑️

Coste marginal cero

Añadir una pieza más al store nunca duele hoy. Por eso siempre se añade y por eso el árbol crece sin que nadie lo decida.

📡

Ruido reactivo

Cuando todo vive en el mismo sitio, todo el mundo está suscrito a cambios que no le incumben y el trabajo inútil se generaliza.

🧭

Triaje de tres preguntas

Quién lo escribe, quién lo lee y cuánto debe vivir. Tres respuestas bastan para devolver cualquier dato a su sitio.

La física del vertedero: por qué se llena solo

Conviene además desactivar desde el principio una confusión que aparece en cuanto se plantea el problema: esto no es una crítica a los stores globales ni a ninguna biblioteca concreta. Un contenedor global bien acotado es una pieza excelente y resuelve un problema real que ninguna otra capa resuelve. El antipatrón no consiste en tener uno, consiste en que el criterio de admisión desaparezca, y precisamente porque la herramienta es buena y su ergonomía es cómoda resulta tan fácil que absorba cosas que no le corresponden. Las herramientas mediocres no generan vertederos; los generan las que funcionan tan bien que nadie tiene un motivo inmediato para dejar de usarlas.

La razón por la que un store global se convierte en vertedero es económica antes que técnica. Poner una pieza más en él tiene coste marginal exactamente cero: el store ya existe, la infraestructura está montada, el acceso desde cualquier punto está resuelto y el desarrollador tarda menos en añadir un campo que en decidir dónde debería vivir de verdad. La alternativa correcta, en cambio, tiene coste inmediato no nulo: hay que averiguar quién es el dueño legítimo del dato, quizá elevarlo hasta un ancestro común, quizá reorganizar unos cuantos componentes. En una economía donde una opción cuesta cero hoy y la otra cuesta algo hoy, el resultado agregado a lo largo de dos años no depende de la calidad del equipo.

Lo que hace la acumulación irreversible en la práctica es que no existe ninguna señal intermedia. Un store con quince campos funciona perfectamente; uno con cuarenta también; uno con ciento veinte sigue funcionando, solo que el equipo ha dejado de entenderlo y ha empezado a tratarlo como un entorno hostil donde se toca lo mínimo indispensable. No hay ningún día en que algo se rompa y obligue a mirar. El deterioro se manifiesta como una subida lenta del coste de cualquier cambio, que es exactamente la clase de señal que ninguna alerta detecta y ninguna métrica de calidad de código recoge.

Hay un segundo mecanismo que refuerza al primero y que rara vez se nombra: el efecto de precedente. Cuando un desarrollador nuevo abre el store y encuentra dentro un campo que representa si un desplegable está abierto, deduce con toda lógica que ese es el sitio donde el equipo pone esa clase de cosas. No está siendo negligente, está leyendo la convención vigente de la única fuente fiable que tiene, que es el código existente. Por eso el vertedero se acelera con el tiempo en lugar de estabilizarse: cada pieza mal colocada no solo pesa por sí misma, sino que autoriza a las siguientes, y ninguna documentación interna compite con la autoridad de lo que ya está escrito.

El síntoma que sí es observable, y conviene buscarlo activamente porque es concluyente, es la aparición de nombres defensivos en el árbol de estado. Cuando encuentras campos llamados uiTemporal, datosVarios, estadoModalDetalle o cualquier variante de un prefijo que agrupa cosas por dónde se usan en vez de por qué son, tienes la confesión escrita: alguien intentó poner orden dentro del vertedero en lugar de vaciarlo, y agrupar la basura no es reciclarla.

Los cuatro impuestos que se pagan por vivir ahí

El primer impuesto es el acoplamiento global. Un dato accesible desde cualquier punto acabará siendo leído desde puntos que nadie previó, y a partir de ese momento su forma no se puede cambiar sin auditar la aplicación entera. Esto convierte refactores que deberían ser locales en operaciones de riesgo, y es la razón por la que en las aplicaciones maduras se dejan campos muertos en el store: nadie está seguro de que no los lea alguien.

Hay una consecuencia del acoplamiento que merece mención aparte porque afecta a algo que los equipos valoran mucho y rara vez relacionan con esta causa: la capacidad de dividir el trabajo. Cuando el estado está bien acotado, dos personas pueden modificar dos funcionalidades a la vez sin coordinarse, porque los ámbitos que tocan son disjuntos. Cuando todo vive en el mismo contenedor, cualquier par de cambios roza el mismo archivo, los conflictos de integración se vuelven rutina y aparece la coordinación de pasillo como sustituto de una separación que el diseño debería haber garantizado. El vertedero no solo encarece el código: encarece la organización que lo escribe.

El segundo impuesto es el ruido reactivo. Cuando cientos de piezas comparten un mismo contenedor observable, cada escritura despierta a un conjunto de suscriptores mucho mayor que el interesado. Las bibliotecas serias mitigan esto con selectores y comparaciones finas, pero la mitigación es un trabajo continuo que hay que hacer bien en cada punto de consumo, mientras que el ruido se genera solo. El resultado típico es un perfil de rendimiento donde ningún componente concreto es lento y sin embargo la aplicación entera trabaja de más.

El tercer impuesto es el ciclo de vida equivocado, y es el más subestimado. Un dato que vive en el store global vive tanto como la sesión, aunque conceptualmente debiera morir al cerrar un panel. De ahí nace toda una familia de bugs con una firma inconfundible: el usuario abre un diálogo y ve los datos del anterior, aplica un filtro en una pantalla y lo encuentra aplicado en otra, cierra un asistente a la mitad y al volver a abrirlo aparece en el paso tres. Cada uno de esos casos se parchea con una limpieza manual al desmontar, lo que añade más código de mantenimiento para sostener una decisión que era el problema original.

El cuarto impuesto es la pérdida de propiedad, que es el que mata la capacidad de razonar. En un store lo bastante grande deja de estar claro quién es el autor de cada campo, cuáles son sus valores legales y qué invariantes lo relacionan con sus vecinos. La consecuencia práctica es que nadie se atreve a borrar nada, y un contenedor del que no se puede borrar solo puede crecer.

Los cuatro impuestos tienen una propiedad común que explica por qué ninguna herramienta los elimina: se pagan en el momento de leer, entender y modificar, no en el de ejecutar. Un store enorme no consume una cantidad relevante de memoria ni ralentiza el arranque de forma medible, y por eso ninguna métrica automática lo señala jamás. Lo que encarece es el trabajo humano de razonar sobre él, que es precisamente la magnitud que ningún panel de control mide y la que domina el coste real de mantener un producto durante años.

// Sintoma tercero: el ciclo de vida equivocado y el parche que genera.
useEffect(() => {
  return () => {
    limpiarFiltrosDelPanel()   // limpieza manual porque el dato vive de mas
    cerrarModalDetalle()       // otra limpieza manual por el mismo motivo
    borrarBorradorNota()       // y otra, y cada una es una que alguien puede olvidar
  }
}, [])
flowchart TD
V[store global convertido en vertedero] --> Q1[quien lo escribe]
V --> Q2[de donde viene la verdad]
V --> Q3[cuanto debe vivir]
Q1 --> L[un solo subarbol: baja a estado local]
Q2 --> C[del servidor: sube a la cache remota]
Q3 --> U[navegable y compartible: sube a la URL]
L --> N[lo que sobrevive es estado global de verdad]
C --> N
U --> N
style V fill:#f38ba8,color:#11111b
style N fill:#a6e3a1,color:#11111b

El triaje: tres preguntas que colocan cualquier dato

El instrumento para vaciar el vertedero es un triaje corto que se aplica pieza por pieza y que casi siempre concluye en menos de un minuto. Primera pregunta: quién lo escribe. Si todas las escrituras salen de un mismo subárbol de la interfaz, el dato pertenece a la raíz de ese subárbol y no al store, por muy cómodo que resulte leerlo desde fuera. La comodidad de lectura es la puerta de entrada más común y también la peor justificación, porque se resuelve pasando el valor o colocando un contexto acotado. Fíjate en que la pregunta es sobre la escritura y no sobre la lectura: un dato leído desde muchos sitios pero escrito desde uno solo sigue teniendo un propietario claro, mientras que un dato leído por dos componentes y escrito por cinco es un problema con independencia de dónde lo guardes.

Segunda pregunta: de dónde viene la verdad. Si el dato es una copia de algo que el servidor posee, no pertenece al store global bajo ninguna circunstancia: pertenece a la capa de cache remota, con su clave, su caducidad y su invalidación. Este es el caso que más volumen ocupa en los vertederos reales, y también el que más código elimina cuando se corrige, porque con el dato se van las acciones, los reductores y los estados de carga que se habían escrito a mano para gestionarlo.

Hay una variante de este caso que se resiste al diagnóstico porque parece legítima: el dato remoto que se copia al store para poder modificarlo localmente antes de enviarlo. Aquí conviven dos cosas distintas y hay que separarlas en lugar de elegir una. Lo que llegó del servidor es estado de servidor y pertenece a la cache; lo que el usuario está modificando es un borrador y pertenece al componente que lo edita, con su momento explícito de confirmación y de descarte. Cuando se guardan juntas en el mismo campo del store, la aplicación pierde la capacidad de responder a la pregunta más básica del formulario, que es si hay cambios sin guardar.

Tercera pregunta: cuánto debe vivir y con quién debe viajar. Si el dato debe sobrevivir a una recarga de la página, aparecer al compartir un enlace o restaurarse al usar el botón de atrás, no pertenece a la memoria del proceso: pertenece a la URL. Filtros, pestaña activa, página de una tabla, criterio de orden y término de búsqueda son estado navegable, y colocarlos en un store es el motivo por el que tantas aplicaciones tienen enlaces que no reproducen lo que el usuario está viendo.

Conviene aplicar el triaje en ese orden concreto y no en otro, porque las tres preguntas no son independientes: la segunda descarta más volumen que la primera y la tercera descarta más que la segunda. Empezar por quién escribe conduce a discusiones largas sobre la topología del árbol de componentes que no hacen falta si el dato resulta ser del servidor, en cuyo caso se va entero y la discusión sobra. Un triaje bien ordenado sobre un store real suele resolver la mitad de los campos con la segunda pregunta y otra cuarta parte con la tercera, dejando para el debate difícil solo lo que queda.

Hay además un cuarto criterio de desempate para los casos que sobreviven a las tres preguntas y siguen pareciendo globales: preguntarse qué debería ocurrir si el usuario abriera la aplicación en dos pestañas a la vez. Si el dato debe ser el mismo en ambas, casi siempre es sesión o preferencia y es global de verdad. Si cada pestaña debería tener el suyo, entonces no era global, era estado de una vista que se había colocado demasiado arriba, y el hecho de que la aplicación se comporte de forma extraña con dos pestañas abiertas es la confirmación empírica del diagnóstico.

💡
La regla de las dos ramas separa lo global de lo elevado

Cuando dudes entre dejar un dato en el store o bajarlo, dibuja mentalmente el árbol de componentes y marca todos los que lo leen o lo escriben. Si todos cuelgan de un mismo ancestro, no es estado global: es estado de ese ancestro, y su sitio correcto es allí, donde su ciclo de vida coincide con el de la funcionalidad a la que sirve. Solo cuando los consumidores están repartidos por ramas verdaderamente disjuntas del árbol, y elevarlo obligaría a llevarlo hasta la raíz atravesando componentes a los que no les incumbe, la globalidad está justificada. Aplicada honestamente, esta regla suele dejar el store en menos de una décima parte de su tamaño original.

El vaciado incremental y la defensa que lo mantiene vacío

Antes de vaciar conviene medir, porque una cifra concreta cambia la conversación mucho más que un argumento bien construido. Registra el árbol del store en un momento cualquiera de uso real, cuenta los campos y clasifícalos con el triaje: el resultado habitual en una aplicación de cierta edad es que entre la mitad y dos tercios del contenido es estado de servidor, entre un diez y un veinte por ciento es navegable, otro tanto es puramente local, y lo que queda —normalmente menos de una décima parte— es lo único que merecía estar ahí. Esa distribución es tan estable entre proyectos distintos que sirve como diagnóstico rápido: si tu store no la reproduce aproximadamente, o bien ya está saneado o bien la clasificación se ha hecho con demasiada indulgencia.

Lo que sobrevive al triaje es poco y tiene un aire de familia reconocible: la sesión y los permisos del usuario, las preferencias de presentación como el tema o el idioma, la conectividad y el modo de trabajo sin red, la cola de notificaciones y algún dato de configuración que atraviesa toda la aplicación. Fíjate en que todos comparten dos rasgos: viven tanto como la sesión y los consume código de ramas genuinamente disjuntas. Ese es el perfil del estado global legítimo, y cualquier campo que no lo cumpla está de paso.

El vaciado no debe hacerse como una migración grande, porque una migración grande compite con el trabajo de producto y pierde siempre. Funciona mucho mejor como un procedimiento incremental con una regla de precedencia: cada vez que alguien toque una funcionalidad, saca del store las piezas de esa funcionalidad. Empieza siempre por el estado de servidor, que es donde está el mayor volumen y donde el beneficio es más visible; sigue por el estado navegable, que suele arreglar bugs reales de compartición y navegación; y deja para el final el estado local, que es el más numeroso pero el menos dañino.

// Antes: cuatro clases de estado revueltas en el mismo contenedor.
const store = {
  usuario: null,          // sesion: global legitimo
  tema: 'oscuro',         // preferencia: global legitimo
  listaPedidos: [],       // servidor: pertenece a la cache
  cargandoPedidos: false, // servidor: lo aporta la cache
  filtroEstado: 'todos',  // navegable: pertenece a la URL
  paginaActual: 1,        // navegable: pertenece a la URL
  modalAbierto: false,    // local: pertenece al componente
  borradorNota: '',       // local: pertenece al formulario
}

// Despues: el store solo conserva lo que ninguna otra capa puede sostener.
const store = { usuario: null, tema: 'oscuro' }

Ese ejemplo condensa el resultado típico de un triaje honesto: de ocho campos sobreviven dos, y los seis que se van no desaparecen sino que se colocan en capas que ya resolvían su problema mejor de lo que el store lo resolvía. Los dos de servidor se llevan consigo las acciones, los reductores y la bandera de carga; los dos navegables ganan gratis persistencia, historial y compartibilidad; los dos locales recuperan un ciclo de vida que coincide con el de la funcionalidad a la que sirven, con lo que desaparecen también las limpiezas manuales al desmontar.

Merece una advertencia el orden de ejecución del vaciado, porque hacerlo al revés es la causa más común de que la iniciativa se abandone. Si empiezas por el estado local, que es el más numeroso, harás muchísimos cambios pequeños con poco beneficio visible y agotarás la paciencia del equipo antes de llegar a lo que importa. Si empiezas por el estado de servidor, el primer recurso migrado suele eliminar entre cien y trescientas líneas y arreglar de paso dos o tres bugs de datos viejos, y ese resultado compra el permiso político para continuar con el resto.

La defensa que impide que se vuelva a llenar no es una norma escrita en un documento, porque las normas escritas no sobreviven a la tercera semana con prisa. Es una fricción deliberada colocada en el punto de entrada: exigir que cada campo nuevo del store llegue con una línea que responda a las tres preguntas del triaje, y revisar esa línea en la revisión de código igual que se revisa un cambio de esquema en una base de datos. La fricción no tiene que ser grande; tiene que ser suficiente para que añadir al store deje de ser el camino de menor resistencia, porque toda esta patología procede exactamente de que lo sea.

Una segunda defensa, complementaria y más barata, consiste en atacar el efecto de precedente en lugar del acto de añadir: mantener el store lo bastante pequeño como para que quepa entero en una pantalla. Un contenedor que se lee de una sola mirada comunica su criterio de admisión sin necesidad de documentarlo, porque quien llega nuevo ve seis campos de sesión y preferencias y entiende de inmediato que lo suyo no encaja ahí. El tamaño, en este caso, no es una consecuencia del diseño sino una herramienta de diseño: es la forma más eficaz de que la convención se transmita sola.

El alcance no es un detalle de implementación: es la única forma de acotar lo que hay que entender para razonar sobre un cambio

Conviene ver este antipatrón en su contexto histórico, porque no es un problema de los stores ni de una biblioteca concreta. Es la reencarnación exacta de la variable global, y la industria ya recorrió este camino entero entre los años sesenta y ochenta hasta llegar a una conclusión que hoy nadie discute en ningún otro ámbito: el alcance de un dato no es una comodidad de acceso, es la declaración formal de cuánto código puede afectarle. Cuando declaras algo global estás afirmando que cualquier línea del programa puede leerlo y escribirlo, y por tanto que para razonar sobre su valor en un instante dado hay que considerar el programa completo. Esa afirmación es lo que destruye la localidad, y la localidad es la única razón por la que un sistema de trescientos mil renglones puede ser modificado por alguien que solo ha leído tres mil. Lo que ocurrió con los stores es que la lección se perdió por un accidente de presentación: como el store se accede mediante un hook o un selector en lugar de un identificador desnudo, y como está tipado, y como los cambios pasan por acciones auditables, se produce la impresión de que es una variable global domesticada. Y en parte lo es, porque la trazabilidad y el tipado resuelven problemas reales del modelo antiguo. Pero ninguna de esas mejoras toca el problema central: el alcance sigue siendo el programa entero. Un campo tipado, auditado y accesible desde cualquier punto sigue obligando a considerar el programa entero cuando cambia su forma o su significado. La consecuencia de diseño que se deriva de esto es la que da nombre a la jerarquía correcta y explica por qué el triaje funciona: el estado debe vivir en el ámbito más pequeño que contenga a todos sus consumidores, ni uno más grande ni uno más pequeño. Más grande y pagas los cuatro impuestos de esta lección; más pequeño y acabas duplicándolo, que es la lección uno del nivel. Todo este nivel de antipatrones, visto desde arriba, describe las dos formas de equivocarse en la misma decisión: el estado duplicado es alcance demasiado pequeño y el vertedero es alcance demasiado grande, y la arquitectura correcta consiste, casi por completo, en acertar con ese tamaño.

⚔️ Vacía tu vertedero sin parar el desarrollo
  1. Exporta el árbol de tu store en un momento cualquiera de uso real y cuenta cuántos campos tiene y cuántos entiendes sin abrir código.
  2. Clasifica cada campo en las cuatro clases: sesión y preferencias, servidor, navegable y local.
  3. Mide el volumen relativo de cada clase y comprueba la hipótesis de que el estado de servidor ocupa la mayor parte.
  4. Migra a la capa de cache remota un solo recurso completo y contabiliza cuántas líneas de acciones y reductores desaparecen.
  5. Mueve a la URL el filtro y la paginación de una tabla y verifica que un enlace copiado reproduce exactamente lo que ves.
  6. Aplica la regla de las dos ramas a cinco campos y baja al menos uno hasta el ancestro común de sus consumidores.
  7. Abre la aplicación en dos pestañas simultáneas y anota qué campos del store se comportan de forma incorrecta al compartirse.
  8. Añade a tu revisión de código la exigencia de justificar cada campo nuevo del store con las tres preguntas del triaje.