wandres.dev
ERRORES COMUNES · y cómo salir

Poner todo en el store global: el síntoma, el coste y el criterio

El store global crece por inercia porque nadie tiene que justificar una adición: cada dato que entra parece inofensivo y el conjunto acaba siendo un espacio de nombres compartido por toda la aplicación. Esta lección nombra el síntoma con precisión, cuantifica el coste en cuatro monedas distintas —acoplamiento, rendimiento, desajuste de ciclo de vida y coste de comprensión—, y propone un criterio operativo de tres preguntas para decidir si un dato merece ser global, debe bajar a estado local o pertenece en realidad a la URL. Incluye el bug del estado zombi, que es la forma en que el desajuste de ciclo de vida se cobra su factura.

⏱ 18 min

Ningún equipo decide poner todo en el store global. La decisión nunca se toma: se acumula. Alguien necesita un dato en dos pantallas y lo sube; alguien encuentra más cómodo despachar que pasar una propiedad tres niveles; alguien copia el patrón del archivo de al lado porque parecía la convención de la casa. Cada uno de esos movimientos es defendible por separado y el conjunto no lo es, porque el store global tiene una propiedad que ningún otro contenedor de estado tiene: no es un almacén, es un espacio de nombres compartido por la aplicación entera. Meter algo ahí no es guardarlo, es publicarlo. Y como publicar no cuesta nada en el momento y cuesta permanentemente después, la asimetría garantiza que el store crezca hasta que alguien reconstruya el criterio que nunca se escribió.

🎯 Al terminar esta lección sabrás
  • Reconocer los síntomas objetivos de un store hipertrofiado sin recurrir a la intuición estética.
  • Cuantificar el coste real en sus cuatro monedas: acoplamiento, rendimiento, ciclo de vida y comprensión.
  • Aplicar tres preguntas para decidir entre estado global, estado local y estado en la URL.
  • Ejecutar la bajada de un dato al componente sin romper a los consumidores intermedios.

El síntoma, dicho sin estética

Un store hipertrofiado se detecta por señales medibles, no por la sensación de que hay demasiado. La primera es la presencia de datos cuya vida útil es más corta que la de la aplicación: si el estado tiene un campo llamado modalAbierto, pestanaActiva, textoDelBuscador o paginaActual, tienes datos efímeros en un contenedor permanente. La segunda es la acción de limpieza: cuando encuentras reducers cuyo único trabajo es reiniciar campos al desmontar una pantalla, estás pagando manualmente por un ciclo de vida que el componente te habría dado gratis. La tercera es el borrador de formulario, el caso más común de todos: cada pulsación de tecla se convierte en un despacho que atraviesa middleware, reducers, suscriptores y comprobaciones de desarrollo, para un dato que nadie fuera de ese formulario leerá jamás.

// Inventario tipico: de siete campos, solo dos pertenecen al store.
interface RootState {
  sesion: { usuario: Usuario | null }           // global: transversal
  tema: 'claro' | 'oscuro'                      // global: preferencia
  modalAbierto: boolean                         // local: muere con la vista
  pestanaActiva: string                         // URL: debe viajar en enlace
  filtros: { texto: string; estado: string }    // URL: recargable
  formulario: { nombre: string; email: string } // local: borrador efimero
  usuarios: { lista: Usuario[]; cargando: boolean } // cache de servidor
}

Hay una cuarta señal más sutil y más diagnóstica que las tres anteriores: mira quién lee cada campo. Si en toda la base de código un slice tiene exactamente un consumidor, y ese consumidor es el mismo componente que despacha las acciones que lo modifican, ese dato no es global. Está en el store por comodidad de acceso, no por necesidad de compartición, y el store se está usando como una variable de módulo con ceremonia añadida.

⚠️
El estado zombi: cuando el ciclo de vida no encaja

El coste que más desconcierta aparece al desmontar y volver a montar. Un dato en estado local nace y muere con el componente; un dato en el store global sobrevive a ambos. Si guardas ahí el paso de un asistente, el filtro de una tabla o el error de validación de un formulario, la próxima vez que el usuario abra esa pantalla la encontrará exactamente como la dejó: en el paso cuatro, con el filtro puesto y el error en rojo de la sesión anterior. El equipo descubre el problema en producción y lo parchea con un efecto que despacha una acción de reinicio al montar, lo cual introduce un render extra, una carrera con la carga de datos y un despacho que aparece en el historial sin que el usuario haya hecho nada. Todo ese aparato existe para simular a mano la destrucción que el estado local hace por construcción. Cuando veas una acción cuyo nombre contenga la palabra reiniciar, pregúntate si lo que hay debajo no es un dato que simplemente no debería haber sobrevivido.

Cuatro monedas distintas

El error habitual al argumentar contra el store hipertrofiado es reducir todo a rendimiento, que es la moneda menos importante y la más fácil de refutar con un perfilado. El coste real se paga en cuatro divisas y solo una de ellas se mide en milisegundos.

🔗

Acoplamiento

Cada campo global es una interfaz pública. Cambiar su forma obliga a auditar toda la aplicación, porque cualquiera pudo haberlo leído sin que exista una lista de quién lo hizo.

Rendimiento

Cada despacho notifica a todos los suscriptores y ejecuta todos sus selectores. Un dato que cambia en cada tecla multiplica ese barrido por la longitud de lo que el usuario escribe.

Ciclo de vida

El estado global vive lo que vive la aplicación. Todo dato cuya vida natural es más corta necesita limpieza manual, y esa limpieza es código que solo existe para deshacer una decisión previa.

🧠

Comprensión

Para entender una pantalla hay que leer su componente. Si su estado está en el store, hay que leer también el slice, sus acciones, sus efectos y sus consumidores desconocidos.

La moneda del rendimiento conviene medirla antes de invocarla, porque su magnitud depende de dos factores que varían mucho entre proyectos: cuántos suscriptores hay y cuánto cuesta cada selector. En una aplicación con cuarenta componentes conectados y selectores triviales, un despacho por pulsación de tecla es perfectamente asumible y quien lo señale como problema estará optimizando por superstición. En una con cuatrocientos y varios selectores que recorren colecciones, el mismo patrón produce una latencia de escritura que el usuario percibe como una interfaz pegajosa. La diferencia no es de arquitectura sino de escala, y por eso este argumento debe apoyarse siempre en un perfilado y nunca en la teoría: los otros tres, en cambio, valen desde la primera línea.

💡
Cinco destinos, no dos

El debate suele plantearse como una disyuntiva entre estado global y estado local, y esa simplificación es la causa de que tantos datos acaben en el sitio equivocado. Los destinos reales son cinco y cada uno tiene un ciclo de vida distinto: el estado local del componente, que muere con él; el estado elevado al ancestro común, para hermanos que comparten; la URL, para todo lo que deba sobrevivir a una recarga o viajar en un enlace; la caché de datos del servidor, que no es estado sino copia; y el store global, reservado a lo que de verdad es transversal y persistente dentro de la sesión. Casi todo lo que sobra en un store hipertrofiado pertenece a uno de los cuatro primeros, y la razón por la que acabó en el quinto es que el equipo solo tenía dos casillas mentales disponibles.

La moneda del acoplamiento merece un desarrollo, porque es la que resulta más cara a largo plazo y la que menos se nota a corto. Un dato local tiene un alcance que el compilador conoce: puedes renombrarlo, cambiar su tipo o eliminarlo, y las herramientas te dirán exactamente quién se rompe. Un dato global tiene un alcance que ninguna herramienta acota, porque cualquier selector escrito en cualquier archivo puede alcanzarlo. En el momento en que un campo lleva seis meses en el store, nadie sabe con certeza quién depende de él, y esa incertidumbre convierte cualquier refactorización en una negociación con el miedo. El estado global no es caro por lo que ocupa: es caro porque no se puede eliminar con confianza.

Tres preguntas para decidir

El criterio útil no es una lista de categorías sino un procedimiento que se pueda aplicar en una revisión de código en quince segundos. Tres preguntas bastan, y el orden importa porque cada una descarta un motivo distinto.

La primera es quién más lo lee. Si la respuesta es un único componente y sus descendientes directos, el dato es local: pásalo por propiedades o guárdalo con el hook de estado del componente. Compartir con dos o tres componentes de la misma rama no justifica el store; justifica subir el estado al ancestro común o, si el árbol es profundo, un contexto acotado a esa rama. El store global se gana cuando los consumidores están en ramas que no se tocan y el ancestro común es la raíz de la aplicación.

La segunda es qué debe pasarle al dato cuando el componente desaparece, y es la más discriminante de las tres porque casi nadie se la hace. Si la respuesta correcta es que el dato muera, es local, y ninguna consideración de comodidad debería cambiarlo. Si el dato debe sobrevivir a la navegación —el usuario cambia de pantalla, vuelve y espera encontrar lo mismo— entonces sí es global, pero fíjate en que acabas de tomar una decisión de producto, no de arquitectura: alguien tiene que haber decidido que esa persistencia es el comportamiento deseado.

Conviene detenerse en el caso intermedio, que es el que más discusiones genera: dos componentes lejanos que comparten un dato efímero, típicamente un panel de filtros y la tabla que filtra. La tentación es que si son dos, ya es global. No lo es: sigue siendo un estado cuya vida es la de la pantalla, y lo que necesita es un dueño común dentro de esa pantalla, no en la raíz de la aplicación. Elevarlo al contenedor de la ruta resuelve el problema con el alcance correcto y conserva la destrucción automática. La distancia en el árbol es un argumento para elegir el mecanismo de transporte —propiedades, contexto acotado, un store por ruta— pero nunca es un argumento para cambiar el ciclo de vida del dato, y confundir ambas cosas es el mecanismo exacto por el que crecen los stores.

La tercera es si debería poder compartirse por enlace o sobrevivir a una recarga. Aquí aparece el contenedor que casi nadie considera y que resuelve una fracción sorprendente de los casos: la URL. Filtros, pestaña activa, página, término de búsqueda, identificador del elemento seleccionado y modo de ordenación pertenecen todos a la barra de direcciones, porque el usuario espera poder copiar el enlace, recargar sin perder el contexto y usar el botón de retroceso. Un filtro guardado en el store es un filtro que se pierde al recargar y que no se puede enviar por mensaje a un compañero, y ese es un defecto funcional antes que arquitectónico.

flowchart TD
A[un dato nuevo] --> B[lo leen ramas distintas del arbol]
B -->|no| L[estado local del componente]
B -->|si| C[debe sobrevivir al desmontaje]
C -->|no| L
C -->|si| D[debe sobrevivir a recarga o enlace]
D -->|si| U[la URL]
D -->|no| G[store global]
style L fill:#a6e3a1,color:#11111b
style U fill:#89b4fa,color:#11111b
style G fill:#cba6f7,color:#11111b
// Sintoma: el borrador de un formulario viviendo en el store global.
const formSlice = createSlice({
  name: 'form',
  initialState: { nombre: '', email: '', tocado: false, error: null },
  reducers: {
    setNombre: (e, a) => { e.nombre = a.payload },
    setEmail: (e, a) => { e.email = a.payload },
    limpiar: () => estadoInicial, // esta accion delata el problema
  },
})

// Refactor: el borrador es local; lo global es el hecho consumado.
const [borrador, setBorrador] = useState({ nombre: '', email: '' })
const enviar = () => dispatch(perfilActualizado(borrador))

La bajada, paso a paso

El refactor de bajada tiene una secuencia que evita romper a los consumidores intermedios, y conviene seguirla en ese orden porque cada paso deja el código en un estado desplegable. Primero inventarías los lectores reales del campo con una búsqueda literal del nombre; casi siempre son menos de los que temías, y ese número es el que decide si el refactor es de media hora o de media semana. Segundo, introduces el estado local en el componente propietario y lo sincronizas temporalmente con el store, de modo que ambas fuentes coexistan sin que nadie se rompa. Tercero, migras los lectores uno a uno a recibir el valor por propiedades o a leerlo del nuevo dueño, verificando cada uno por separado. Cuarto, eliminas el campo del slice y compruebas que la comprobación de tipos falla en cero sitios, que es la prueba objetiva de que el inventario era completo. Y quinto, borras las acciones huérfanas, que en un slice de este tipo suelen ser la mitad del archivo.

// Paso 2: coexistencia temporal. Nada se rompe mientras dura la migracion.
const [pagina, setPagina] = useState(paginaDelStore)
useEffect(() => { dispatch(paginaCambiada(pagina)) }, [pagina])

// Paso 4: cuando el ultimo lector externo ha migrado, el campo se borra
// y el estado local queda como unica fuente. La sincronizacion desaparece.
const [pagina, setPagina] = useState(1)

Merece la pena insistir en el paso cuatro, porque es el único que produce una verificación objetiva y casi todo el mundo lo omite por miedo. Borrar el campo y ejecutar la comprobación de tipos convierte una intuición —creo que ya no lo usa nadie— en un hecho comprobado por la máquina. Si aparecen errores, no has fallado: has encontrado exactamente a los consumidores que tu inventario se había dejado, con nombre de archivo y número de línea. Un refactor que termina sin ese momento de verificación no ha terminado, ha sido interrumpido.

Hay un rodeo tentador en el paso tres que conviene desaconsejar de forma explícita: sustituir el store global por un contexto que envuelve la aplicación entera. No resuelve nada, porque reproduce las cuatro monedas del coste con una implementación peor —sin selectores, sin comparación granular y renderizando toda la rama en cada cambio— y añade la ilusión de haber refactorizado. El contexto es una herramienta correcta cuando su alcance es una rama concreta del árbol y su valor cambia con poca frecuencia; usado como store global, es un store global con menos herramientas.

Conviene además cerrar el refactor con una decisión de proceso, porque de lo contrario el store volverá a crecer por el mismo mecanismo que lo hizo la primera vez. Escribe la regla de admisión en el archivo de convenciones del equipo, formulada como pregunta y no como principio: qué componentes leerán este dato, qué debe pasarle cuando su pantalla desaparezca y si tendría que sobrevivir a una recarga. Un criterio que cabe en tres preguntas se aplica en una revisión de código; uno que exige un juicio arquitectónico no se aplica nunca, y esa es la diferencia real entre una convención viva y un documento.

El store no es un almacén: es la superficie pública de tu aplicación

La razón profunda por la que este error se comete una y otra vez, incluso por equipos que conocen la teoría, es que el store se presenta con una metáfora equivocada. Llamarlo almacén o contenedor sugiere un espacio pasivo donde las cosas se guardan, y contra esa imagen la única objeción imaginable es el tamaño: un armario más lleno funciona igual, solo cuesta un poco más encontrar las cosas. Pero un store global no es un armario, es un espacio de nombres compartido con alcance de aplicación, y eso lo emparenta con las variables globales de cualquier lenguaje, con las tablas de una base de datos compartida y con las interfaces públicas de una librería. En los tres casos la propiedad decisiva es la misma y no tiene nada que ver con el volumen: lo que se publica ahí deja de tener un conjunto conocido de consumidores. Un dato local puede cambiar de forma, de nombre o de existencia con la seguridad de que el impacto está acotado por el alcance léxico; un dato global solo puede cambiar con la esperanza de que nadie más dependiera de él en una forma que las herramientas no puedan ver. Por eso la pregunta correcta ante cada campo candidato no es si cabe en el store ni si es cómodo tenerlo allí, sino si estás dispuesto a mantener ese campo como una interfaz estable durante el resto de la vida del proyecto, con todo lo que eso implica: documentarlo, versionarlo cuando persistas el estado, migrarlo cuando cambie de forma y no poder eliminarlo sin una auditoría. La disciplina de mantener el store pequeño no es minimalismo ni gusto por lo austero; es la misma prudencia que hace que una librería exporte tres funciones en vez de treinta. Todo lo que publicas te lo quedas.

⚔️ Adelgaza tu store con criterio
  1. Lista todos los campos de tu store y anota, para cada uno, cuántos componentes distintos lo leen realmente; ordena la lista por ese número.
  2. Marca todos los campos con un solo lector y comprueba si ese lector es también quien despacha sus acciones: esos son candidatos inmediatos a bajar.
  3. Busca todas las acciones cuyo nombre contenga reiniciar o limpiar y averigua qué desajuste de ciclo de vida está compensando cada una.
  4. Identifica los datos que deberían vivir en la URL —filtros, pestaña, página, orden— y comprueba qué se rompe hoy al recargar la página con ellos puestos.
  5. Ejecuta la bajada completa de un campo siguiendo los cinco pasos y verifica que el borrado final no produce ni un solo error de tipos.
  6. Escribe en el archivo de convenciones del equipo la regla de admisión al store en una sola frase, y aplícala como criterio explícito en la próxima revisión de código.