Diseñar el estado de una app grande: el plano completo
Segunda lección del nivel de síntesis: pasar del modelo mental a un plano ejecutable para un producto real y grande. Fija el reparto en cuatro territorios con dueños distintos —servidor, URL, cliente compartido y local— y demuestra que la clasificación no es taxonomía académica sino la decisión que determina qué se persiste, qué se invalida, qué se comparte por enlace y qué muere con el componente. Recorre después el plano completo de un panel real pieza a pieza, se detiene en las cuatro costuras donde los territorios se tocan y donde nacen casi todos los bugs de estado —el dato del servidor que se copia al cliente, el filtro que vive en dos sitios, el formulario que edita una cache y la sesión que atraviesa todo—, y termina con el procedimiento para escribir el plano en una página y defenderlo de la erosión que trae cada sprint.
Un modelo mental correcto no impide construir una aplicación desastrosa: solo te da el vocabulario para describir el desastre con precisión. El salto que faltaba es el del modelo al plano, y es un salto de naturaleza distinta, porque el plano no se deduce de principios sino que se decide, pieza a pieza, con información incompleta y bajo presión de calendario. Esta lección propone un procedimiento para tomar esas decisiones en el orden correcto y dejarlas escritas, partiendo de una observación incómoda: en casi todas las aplicaciones grandes que envejecen mal, el problema no fue elegir mal la librería sino no haber decidido nunca, explícitamente, de quién era cada dato. El plano es el documento que hace imposible seguir sin decidirlo.
- Repartir cualquier estado entre los cuatro territorios y justificar el reparto por la naturaleza del dato.
- Construir el plano completo de un producto real, pieza a pieza y con su herramienta asignada.
- Reconocer las cuatro costuras donde los territorios se tocan y neutralizar los bugs que generan.
- Escribir el plano en una página y protegerlo de la erosión de cada nueva funcionalidad.
Cuatro territorios y un solo criterio de reparto
El reparto se decide con una sola pregunta, hecha antes que ninguna otra: quién posee la verdad de este dato. No dónde es cómodo guardarlo, no qué librería ya tienes instalada, no qué hicimos la vez anterior. Quién lo posee. La respuesta cae siempre en uno de cuatro territorios, y cada territorio impone un conjunto de obligaciones distinto que no se puede negociar sin pagarlo después.
Servidor · verdad ajena
Otros la cambian sin avisarte. Obliga a caducidad, revalidación, deduplicación e invalidación. Es cache, nunca estado.
URL · verdad compartible
Debe sobrevivir a un recargado y viajar en un enlace. Filtros, orden, página, pestaña, término de búsqueda, identificador del detalle abierto.
Cliente · verdad propia
Nadie la cambia a tus espaldas y varias partes la leen. Sesión, tema, preferencias, carrito, borradores, cola sin conexión.
Local · verdad efímera
Un solo componente la usa y muere con él. Un menú abierto, un campo a medio escribir, el paso de un asistente.
El territorio determina propiedades que van mucho más allá de la elección de librería. Determina si el dato se persiste y dónde: la URL se persiste sola, el cliente compartido a veces, la cache nunca de forma ingenua y lo local jamás. Determina quién lo invalida: el servidor por etiquetas o tiempo, la URL por navegación, el cliente por una acción explícita, lo local por desmontaje. Determina qué pasa con dos pestañas abiertas: la cache se revalida, la URL diverge legítimamente, el cliente compartido quizá deba sincronizarse, lo local es independiente por definición. Y determina cómo se prueba: la cache con un servidor simulado, la URL con un enrutador, el cliente con acciones, lo local con la propia interfaz.
Cuando una pieza parece caer entre dos territorios, estas tres preguntas la desempatan casi siempre. ¿Otro usuario puede cambiar este dato ahora mismo? Si sí, es servidor y punto, aunque tú también lo escribas. ¿Debería poder enviar este estado de la pantalla por mensaje a un compañero y que él vea lo mismo? Si sí, es URL, aunque te resulte más cómodo un store. ¿Qué pasa si recargo la página en mitad de esto? Si la respuesta correcta es que no debe perderse, no es local, y descubrir eso el día del incidente sale mucho más caro que decidirlo ahora.
Conviene subrayar que estos cuatro territorios no son cuatro librerías. Un equipo puede implementar los cuatro con Redux Toolkit y otro puede repartirlos entre TanStack Query, el enrutador, Zustand y useState, y ambos tendrán el mismo plano si el reparto de dueños coincide. La confusión entre territorio y herramienta es la que produce esas discusiones de equipo que no avanzan nunca, en las que una parte defiende centralizar y la otra fragmentar sin que ninguna haya dicho todavía de quién es cada dato. El plano corta esa discusión en seco porque obliga a hablar del dominio antes que del catálogo.
El plano de un producto real, pieza a pieza
Un reparto abstracto convence a todo el mundo y no cambia nada. Lo que cambia el resultado es la tabla concreta, con nombres de dominio propios, escrita antes de programar. Tomemos un panel de gestión con listado, detalle, edición, notificaciones en vivo y trabajo sin conexión —lo bastante grande para que las decisiones duelan— y desglosemos su estado hasta el fondo.
La columna del dueño es la única que se decide; todas las demás se deducen de ella.
| Pieza de estado | Dueño | Herramienta | Invalidación |
|---|---|---|---|
| Listado de pedidos | Servidor | Cache con etiquetas | Por etiqueta y por foco |
| Detalle de un pedido | Servidor | Cache con clave por id | Al mutar ese pedido |
| Filtros, orden y página | URL | Parámetros de búsqueda | Al navegar |
| Pedido abierto en el panel lateral | URL | Parámetro de búsqueda | Al cerrar |
| Sesión y permisos | Cliente | Store compartido | Al renovar o cerrar sesión |
| Tema e idioma | Cliente | Store persistido | Manual |
| Borrador de edición | Cliente | Store por entidad | Al enviar o descartar |
| Cola de acciones sin conexión | Cliente | Store persistido | Al vaciarse con éxito |
| Notificaciones en vivo | Servidor | Socket que inyecta en cache | Al leerlas |
| Menú abierto, foco, hover | Local | Estado del componente | Al desmontar |
Merece la pena discutir las dos filas que casi siempre se colocan mal. El borrador de edición es la primera: el pedido que estás editando existe en el servidor, luego la tentación es editar directamente el objeto de la cache. Es un error, porque un borrador es un estado del cliente que aún no es verdad en ningún sitio, y mezclarlo con la copia del servidor hace que una revalidación en segundo plano pueda borrar lo que el usuario estaba escribiendo. El borrador vive en el cliente, se inicializa desde la cache y solo al enviarse se convierte en una mutación que invalida la etiqueta correspondiente.
La segunda es el pedido abierto en el panel lateral. Parece estado de interfaz —qué panel está abierto— y por eso suele terminar en un booleano local. Pero si el usuario debe poder recargar sin perder el panel, o enviar el enlace a un compañero para que vea el mismo pedido, entonces no es interfaz: es navegación, y su sitio es la URL. La regla general que se deduce es que la frontera entre lo local y la URL no la marca si el dato parece visual, sino si su pérdida en un recargado sería aceptable para el usuario.
flowchart TD
D[dato nuevo] --> Q1{lo cambia otro usuario}
Q1 -->|si| SRV[servidor y cache con etiquetas]
Q1 -->|no| Q2{debe sobrevivir a recarga o viajar en enlace}
Q2 -->|si| URL[parametros de la url]
Q2 -->|no| Q3{lo leen varias partes lejanas}
Q3 -->|si| CLI[store de cliente]
Q3 -->|no| LOC[estado local del componente]
style SRV fill:#89b4fa,color:#11111b
style URL fill:#f9e2af,color:#11111b
style CLI fill:#a6e3a1,color:#11111b
style LOC fill:#cba6f7,color:#11111bLas cuatro costuras donde nacen los bugs
Los territorios son fáciles por dentro y difíciles en los bordes. Casi todo el sufrimiento de una arquitectura de estado se concentra en cuatro costuras, y conocerlas por su nombre hace que se reconozcan antes de haberlas construido.
La primera y más frecuente es copiar del servidor al cliente. Alguien necesita el nombre del usuario en un sitio incómodo y lo guarda en el store al recibirlo. A partir de ese instante hay dos copias del mismo hecho con políticas de actualización distintas, y la desincronización es cuestión de semanas. La solución no es sincronizarlas mejor: es leer siempre de la cache y aceptar que la cache ya está compartida globalmente.
La segunda es el filtro que vive en dos sitios: en la URL para poder compartirse y en un store para que los componentes lo lean cómodamente. Cada uno intenta seguir al otro con efectos, y aparecen bucles, parpadeos y estados imposibles tras el botón de atrás. La URL debe ser la única fuente y el store, si existe, solo un derivado de lectura sin escritura propia.
Cada vez que escribas un efecto cuyo único propósito sea copiar un valor de un sitio a otro para mantenerlos iguales, has confesado que hay dos dueños para un mismo dato. La sincronización no arregla ese problema: lo administra, y lo hace con un coste creciente, porque cada nuevo camino de escritura multiplica los casos que el efecto debe cubrir. El arreglo real siempre consiste en eliminar una de las dos copias y derivarla de la otra. Si al intentarlo descubres que no puedes eliminarla porque hay código que la escribe, has encontrado exactamente el sitio que hay que reescribir, y el efecto de sincronización estaba ocultándotelo.
// Costura del formulario: el borrador es del cliente, la verdad es del servidor.
const borradores = createSlice({
name: 'borradores',
initialState: {} as Record<string, Pedido>,
reducers: {
abrir: (s, a: PayloadAction<Pedido>) => { s[a.payload.id] = a.payload },
editar: (s, a: PayloadAction<Parcial>) => { Object.assign(s[a.payload.id], a.payload.cambios) },
descartar: (s, a: PayloadAction<string>) => { delete s[a.payload] },
},
})
// El envio es el unico punto donde el borrador se convierte en verdad.
const guardar = api.injectEndpoints({
endpoints: (b) => ({
guardarPedido: b.mutation({
query: (p: Pedido) => ({ url: `/pedidos/${p.id}`, method: 'PUT', body: p }),
invalidatesTags: (_r, _e, p) => [{ type: 'Pedido', id: p.id }, { type: 'Pedido', id: 'LISTA' }],
}),
}),
})
La tercera costura es el formulario sobre una cache, ya discutida: se resuelve con un borrador de cliente inicializado desde el servidor, envío explícito e invalidación posterior. La cuarta es la sesión, que atraviesa los cuatro territorios a la vez: decide qué peticiones se hacen, qué rutas son accesibles, qué se guarda y qué se muestra. Por eso merece un tratamiento aparte y una regla estricta: al cambiar la identidad, se vacía la cache entera, se limpia lo persistido y se reinicia el store de cliente. La mayoría de las fugas de datos entre cuentas en aplicaciones de una sola página vienen de no haber escrito esa regla.
Un plano de veinte filas se lleva en la cabeza; uno de doscientas, no. A partir de cierto tamaño conviene añadir una segunda dimensión al plano, que es el ámbito: qué estado es global de verdad, qué estado pertenece a un módulo de producto y qué estado pertenece a una pantalla. Esa dimensión no cambia el criterio de dueño, pero decide dónde se declara cada pieza y qué equipos pueden tocarla, y es la que permite dividir el store por funcionalidades sin que nadie tenga que leer el árbol entero. La señal de que hace falta introducirla es concreta: cuando el archivo que compone el store necesita conocer los nombres internos de tres equipos distintos, el plano ya no es un documento de arquitectura sino un cuello de botella organizativo.
Escribir el plano y defenderlo del tiempo
Un plano que vive en la cabeza del arquitecto no es un plano. El artefacto que funciona es una página en el repositorio, junto al código, con la tabla anterior rellenada con los nombres reales del dominio, y una regla de gobierno de una sola frase: ninguna pieza de estado nueva entra sin su fila. Esa fila es barata de escribir y cara de omitir, porque obliga a decidir el dueño antes de escribir la primera línea, que es el único momento en que decidirlo es gratis.
Una página, no un documento
Si el plano no cabe en una pantalla, nadie lo leerá y dejará de ser cierto en dos meses. La tabla es el plano; el resto es prosa opcional.
Una fila por pieza
La revisión de código pregunta por la fila antes que por la implementación. Sin dueño declarado, el cambio no entra.
Auditoría trimestral
Recorrer el store buscando datos de servidor copiados, filtros duplicados y persistencias olvidadas. Media hora cada tres meses.
El plano manda sobre la comodidad
Cuando el plano dice URL y el store es más cómodo, gana el plano. La comodidad se paga una vez; el dueño equivocado, cada sprint.
La erosión del plano tiene un patrón reconocible y casi siempre el mismo origen: una funcionalidad urgente necesita un dato en un sitio incómodo, alguien lo copia al store con la intención sincera de arreglarlo después, y esa copia se convierte en la fuente de la que beben tres pantallas nuevas. Seis meses más tarde, eliminarla ya no es una limpieza sino una migración. Por eso la defensa efectiva no es la disciplina individual sino el ritual: la fila obligatoria en la revisión y la auditoría periódica. Ambas son mecanismos baratos que convierten una virtud —recordar el plano— en un procedimiento que no depende de que nadie la tenga.
Conviene nombrar con exactitud qué clase de documento es un plano de estado, porque su aspecto de tabla de asignación de librerías esconde su verdadera naturaleza, que es la de un reparto de autoridad epistémica dentro de un sistema. Cada fila responde a una pregunta que no es técnica sino de gobierno: cuando dos partes del programa afirman cosas distintas sobre el mismo hecho del mundo, ¿cuál de las dos tiene derecho a ser creída? Una aplicación sin plano no es una aplicación desordenada, es una aplicación sin jurisdicción, en la que cada componente que guarda una copia de un dato se ha arrogado en silencio la potestad de contradecir al resto, y en la que el bug de desincronización no es un fallo de programación sino la manifestación inevitable de un conflicto de soberanía que nadie resolvió. Esto explica dos cosas que de otro modo resultan desconcertantes. La primera, por qué cambiar de librería casi nunca arregla estos problemas: mover un dato con dos dueños de Redux a Zustand lo deja exactamente con dos dueños, porque la herramienta jamás fue la variable relevante. La segunda, por qué el plano hay que escribirlo antes y no después: una vez que un dato tiene dos fuentes y ambas están en producción, la decisión de cuál es la verdadera deja de ser un juicio de diseño y pasa a ser una negociación con el comportamiento observable de un sistema en uso, en el que hay pantallas que dependen de la copia equivocada y usuarios acostumbrados a su latencia. La madurez de un equipo frente al estado no se mide, por tanto, en qué patrones conoce, sino en cuántas de sus piezas de datos tienen un dueño declarado por escrito y en cuánto tarda cualquiera en decir, sin abrir el código, quién manda sobre cualquiera de ellas. Ese número —y no la elección de gestor— es el que predice si la aplicación seguirá siendo modificable dentro de tres años.
- Enumera veinte piezas de estado reales de tu producto, con sus nombres de dominio, sin agrupar ni idealizar.
- Asigna a cada una su dueño respondiendo únicamente a la pregunta de quién posee la verdad, antes de pensar en herramientas.
- Rellena la tabla completa con herramienta e invalidación deducidas del dueño, no elegidas aparte.
- Busca las cuatro costuras: datos de servidor copiados al store, filtros duplicados, formularios que editan la cache y limpieza de sesión incompleta.
- Localiza todos los efectos cuyo único propósito sea copiar un valor de un sitio a otro y anota, para cada uno, cuál de las dos copias debe desaparecer.
- Añade el plano al repositorio y propón en tu equipo la regla de la fila obligatoria. Fija fecha para la primera auditoría trimestral.