wandres.dev
PATRONES AVANZADOS · undo, optimista, entity

Estado por instancia: varios formularios y modales del mismo tipo a la vez

Casi todos los slices se escriben suponiendo en silencio que solo habrá uno de cada cosa: un formulario de edición, un modal abierto, un panel de detalle. La suposición aguanta hasta el día en que el producto pide dos, y entonces el problema no es añadir un campo sino que la identidad de la instancia nunca existió en el modelo. Esta lección introduce esa identidad como concepto de primera clase: cómo elegir una clave, cómo indexar el estado por ella, qué le pasa a la memoización de los selectores cuando hay varias instancias vivas, cómo gestionar el alta y la baja sin dejar estado huérfano, y cuándo la respuesta correcta es que ese estado no debería estar en el store.

⏱ 18 min

Hay una suposición que se cuela en casi todos los slices sin que nadie la escriba y sin que nadie la revise: que de cada cosa habrá una. Un formulario de edición, luego el formulario; un modal abierto, luego el modal; un panel de detalle, luego el detalle. La suposición es tan cómoda que se convierte en la forma del estado —campos sueltos en la raíz del slice— y sobrevive intacta durante meses porque nadie la contradice. El día que el producto pide dos pestañas de edición simultáneas, dos modales apilados o dos paneles comparando registros distintos, el trabajo no consiste en añadir una funcionalidad: consiste en descubrir que la identidad de la instancia nunca existió en el modelo y en introducirla a posteriori en cada acción, cada reducer, cada selector y cada componente. Esta lección trata esa identidad como lo que es, un concepto de primera clase, y muestra que introducirla desde el principio cuesta casi nada mientras que introducirla después cuesta una refactorización.

🎯 Al terminar esta lección sabrás
  • Reconocer el singleton implícito en un slice y anticipar el punto exacto en que se rompe.
  • Elegir una clave de instancia y decidir si la genera el sistema o la deriva el dominio.
  • Preservar la memoización de los selectores cuando hay varias instancias vivas a la vez.
  • Gestionar el alta y la baja de instancias sin dejar estado huérfano ni identificadores colgantes.

El singleton implícito y su punto de ruptura

Un slice de formulario típico guarda los valores, los errores de validación, una marca de envío en curso y quizá el identificador del registro que se está editando. Todos esos campos cuelgan directamente de la raíz, y esa disposición codifica una afirmación que nadie discutió: existe como máximo un formulario de este tipo en toda la aplicación. Mientras la afirmación sea verdadera el diseño es óptimo, porque es el más simple posible. El problema es que su falsedad no se manifiesta como un error sino como interferencia: dos componentes montados leen el mismo estado, el segundo pisa los valores del primero, cerrar uno limpia el otro, y el envío de uno muestra el indicador de carga en ambos.

Merece la pena reconocer las señales antes de que la ruptura ocurra, porque son bastante reconocibles. Un campo llamado abierto o editando en singular. Una acción que se llama abrir sin decir qué se abre. Un reducer de cierre que restablece el slice entero al estado inicial. Un selector que devuelve el elemento seleccionado sin recibir ningún argumento. Cada una de esas cosas es un lugar donde el modelo asume unicidad, y todas ellas tendrán que cambiar el día que la unicidad deje de valer. La pregunta útil en el momento de diseñar no es cuántos habrá, sino si el producto puede llegar a querer dos; si la respuesta es sí, indexar desde el principio cuesta un nivel de anidamiento y un argumento.

🚨

Campos en singular

Un abierto o un editandoId en la raíz del slice declara que solo puede haber uno, aunque nadie lo haya decidido.

🧹

Cierre que resetea todo

Un reducer que devuelve el estado inicial al cerrar es el que borrará el trabajo de la otra instancia.

🎯

Selectores sin argumento

Si leer no exige decir cuál, el modelo no admite dos. El argumento es la instancia haciéndose visible.

🔑

Acciones sin destinatario

Una acción de cambio que no lleva clave no puede dirigirse a una instancia concreta y siempre acabará afectando a la equivocada.

La clave: qué identifica a una instancia

Elegir la clave es la decisión de diseño de esta lección y admite dos familias con consecuencias distintas. La clave de dominio identifica la instancia por aquello sobre lo que opera: el formulario del usuario con identificador dado, el panel del pedido concreto. Su virtud es la deduplicación natural —abrir dos veces el mismo registro reutiliza la misma instancia, con su estado y su borrador intactos— y su límite es que impide tener dos instancias del mismo objeto, cosa que a veces se quiere. La clave sintética es un identificador generado en el momento de la apertura, sin relación con el dominio; permite tantas instancias como se abran, incluso sobre el mismo registro, a cambio de que nadie pueda reencontrar una instancia sin haber guardado su clave.

interface EstadoFormulario {
  valores: Record<string, unknown>
  errores: Record<string, string>
  enviando: boolean
  sucio: boolean
}

const slice = createSlice({
  name: 'formularios',
  initialState: { porClave: {} as Record<string, EstadoFormulario> },
  reducers: {
    abierto: {
      reducer: (s, a: PayloadAction<{ clave: string; inicial: EstadoFormulario }>) => {
        // Idempotente: reabrir no destruye el borrador que ya existia.
        if (!s.porClave[a.payload.clave]) s.porClave[a.payload.clave] = a.payload.inicial
      },
      prepare: (inicial: EstadoFormulario, clave = nanoid()) => ({ payload: { clave, inicial } }),
    },
    campoCambiado(s, a: PayloadAction<{ clave: string; campo: string; valor: unknown }>) {
      const f = s.porClave[a.payload.clave]
      if (!f) return               // instancia ya cerrada: ignorar, no crear
      f.valores[a.payload.campo] = a.payload.valor
      f.sucio = true
    },
    cerrado(s, a: PayloadAction<string>) {
      delete s.porClave[a.payload]  // la baja es explicita y local
    },
  },
})

Fíjate en dos detalles que separan una implementación robusta de una frágil. La apertura es idempotente: si la clave ya existe no se reinicializa, porque reabrir un formulario que el usuario tenía a medias no debe borrar lo que llevaba escrito. Y toda acción dirigida a una instancia comprueba su existencia y no hace nada si falta, en lugar de crearla al vuelo; una acción tardía procedente de una instancia ya cerrada —el resultado de una petición en vuelo, por ejemplo— resucitaría la instancia y dejaría un modal fantasma en el estado que nada volvería a cerrar.

La memoización cuando hay varias instancias vivas

Aquí aparece el efecto no evidente y el motivo por el que esta lección pertenece a un nivel avanzado. Un selector memoizado clásico guarda el resultado de la última invocación: si dos componentes lo llaman alternadamente con claves distintas, cada llamada invalida la caché de la otra y la memoización deja de servir para nada sin producir ningún error. El fenómeno se conoce como golpeo de caché y su síntoma es que un selector que funcionaba perfecto con una instancia se recomputa en cada render en cuanto hay dos.

// Fabrica: una cache por instancia, estable a lo largo de los renders.
const crearSelectorResumen = () =>
  createSelector(
    [(s: RootState, clave: string) => s.formularios.porClave[clave]],
    (f) => (f ? resumir(f.valores) : null),
  )

// En el componente: se construye una vez por instancia montada.
const seleccionarResumen = useMemo(crearSelectorResumen, [])
const resumen = useSelector((s: RootState) => seleccionarResumen(s, clave))

Existen tres respuestas. La primera es la fábrica de selectores: una función que crea un selector nuevo por instancia, de modo que cada uno tiene su propia caché de tamaño uno; en un componente hay que construirla con memoización de referencia para que sobreviva a los renders. La segunda es aumentar el tamaño de la caché del selector, útil cuando el número de instancias simultáneas está acotado y es pequeño. La tercera, que es la que hoy resuelve el problema por defecto, es la memoización basada en referencias débiles que incorporan las versiones actuales de la librería de selectores: cachea por argumento sin límite fijo y sin retener memoria de instancias que ya nadie usa.

⚠️
Un selector compartido entre instancias no falla: se degrada

La consecuencia de no atender a esto no es una pantalla rota sino una pantalla lenta, lo cual es peor porque nadie abre un informe de fallo por ello. Dos paneles abiertos convierten un cálculo memoizado en un cálculo por render, cuatro paneles lo multiplican, y el perfil de rendimiento acusa al componente que muestra los datos en lugar de al selector que los deriva. La prueba diagnóstica es directa y vale la pena tenerla escrita: instrumenta el cuerpo de la función de resultado con un contador y comprueba que el número de recomputaciones no crece cuando abres la segunda instancia. Si crece, tu memoización es de tamaño uno y tienes tantas instancias como cachés necesitas.

flowchart TD
R[rama de formularios] --> M[mapa por clave]
M --> A[instancia a]
M --> B[instancia b]
A --> SA[selector con cache propia]
B --> SB[selector con cache propia]
SA --> VA[panel a]
SB --> VB[panel b]
X[selector unico compartido] -.->|cache de tamano uno| Y[recomputa en cada render]
style Y fill:#f38ba8,color:#11111b
style M fill:#89b4fa,color:#11111b

Alta, baja y la pregunta de si esto debe estar en el store

El estado por instancia introduce un problema que el estado global no tiene: alguien debe darlo de baja. Mientras el ciclo de vida de la instancia coincide con el de un componente montado, la baja al desmontar es la política natural y basta con despachar el cierre en la limpieza del efecto. Pero esa política es incorrecta en dos escenarios frecuentes: cuando el usuario navega temporalmente fuera y espera volver con su borrador intacto, y cuando la instancia tiene una petición en vuelo cuyo resultado todavía debe procesarse. En ambos casos la baja debe ser explícita y del dominio —guardar, cancelar, descartar— y no un efecto colateral del ciclo de vida de la vista.

Conviene separar además dos cosas que la baja mezcla y que tienen dueños distintos: la visibilidad y los datos. Cerrar un modal es esconderlo; descartar su borrador es tirar el trabajo del usuario. Modelarlas con el mismo reducer obliga a elegir entre perder datos al cerrar por accidente o acumular basura por no borrar nunca. Separadas, la política se vuelve articulable y hasta agradable: cerrar oculta y conserva, descartar borra tras confirmación, y una limpieza por antigüedad se lleva lo que lleve horas sin tocarse. Que esa tercera política exista es la señal de que has tratado el estado por instancia como un recurso y no como una variable.

La fuga que resulta de no decidirlo es lenta y silenciosa: cada apertura añade una clave y ninguna la quita, el mapa crece durante toda la sesión, y si además el estado se persiste, el crecimiento sobrevive a los reinicios. Conviene tratar el mapa de instancias como cualquier recurso con dueño: si nadie es responsable de liberar, nadie libera. Una comprobación barata es exponer en desarrollo el número de claves vivas y observar que vuelve a su nivel base después de abrir y cerrar veinte veces.

Existe una variante del problema que aparece con los modales y que el mapa por clave no resuelve por sí solo: el apilamiento. Dos modales abiertos no son solo dos instancias, son dos instancias con un orden y una relación de encima y debajo, y ese orden es estado que alguien tiene que guardar. Un mapa desordenado no puede decir cuál está delante ni cuál debe cerrarse al pulsar escape. La solución es la misma que en cualquier colección: el mapa guarda las instancias y una lista aparte guarda su orden, con la cima al final. Cerrar por escape saca el último, y cerrar uno concreto lo elimina de ambas estructuras.

interface EstadoModales {
  porClave: Record<string, EstadoModal>
  pila: string[]   // el orden es estado, y no vive en el mapa
}

La otra variante frecuente es la instancia derivada de la ruta. Cuando la clave es un parámetro de la dirección, la navegación se convierte en el mecanismo de alta y baja y conviene no duplicar esa responsabilidad: si el estado de la instancia se puede reconstruir de la ruta y de los datos, no lo guardes; si es un borrador que el usuario ha escrito, guárdalo indexado por la misma clave que la ruta usa, para que volver a la dirección recupere exactamente lo que había. Esa coincidencia entre la clave de la ruta y la clave de la instancia es lo que hace que el botón de atrás funcione sin código adicional.

ℹ️
La pregunta previa: ¿por qué está esto en el store?

Buena parte del estado por instancia no debería vivir en el store en absoluto. Si su duración coincide exactamente con la del componente, nadie más lo lee y no sobrevive a la navegación, el estado local del componente lo modela mejor y resuelve el ciclo de vida gratis. El estado por instancia pertenece al store cuando debe sobrevivir al desmontaje, cuando lo consulta alguien que no es el componente que lo produjo, cuando participa en efectos o persistencia, o cuando su historial importa. Los modelos atómicos ofrecen aquí una tercera vía interesante, con familias de átomos indexadas por clave que dan identidad explícita y liberación automática cuando nadie los observa; el criterio de elección no es la potencia sino quién debe ser el responsable de la baja.

No hay estado singular: hay estado cuya multiplicidad todavía no ha llegado, y modelarlo en singular es apostar contra el producto

Conviene extraer de esta lección el principio general, porque va mucho más allá de los modales y los formularios. Cada vez que escribes un campo en la raíz de un slice estás afirmando una cardinalidad —de esto hay uno— y esa afirmación tiene exactamente el mismo estatuto que un tipo: es una restricción sobre los estados posibles del sistema. La diferencia con un tipo es que nadie la escribió, nadie la revisa y ninguna herramienta la comprueba, de modo que cuando resulta falsa no falla nada: simplemente el programa empieza a hacer cosas raras que se atribuyen a la vista. Y la cardinalidad es, de todas las decisiones de modelado, la que el producto contradice con más frecuencia, porque la dirección del cambio en las interfaces es siempre la misma: lo que hoy es una pantalla mañana son dos pestañas, lo que hoy es un modal mañana es una pila de modales, lo que hoy es un panel mañana es una comparación lado a lado. Nadie pide nunca lo contrario. De ahí se sigue una asimetría práctica que conviene tener presente al diseñar: introducir la identidad de instancia desde el principio cuesta un nivel de anidamiento y un argumento en cada acción, un precio que se paga una vez y en una sola tarde; introducirla después cuesta tocar todas las acciones, todos los reducers, todos los selectores y todos los componentes que los usan, con la agravante de que para entonces hay estado persistido con la forma antigua y datos de usuarios reales que migrar. Es la misma estructura de decisión que gobierna la normalización, y la respuesta madura no es indexarlo todo por si acaso —eso es ceremonia sin beneficio— sino hacerse la pregunta explícitamente y dejar la respuesta escrita. Un comentario que diga por qué aquí solo puede haber uno vale más que cualquier abstracción defensiva, porque convierte una suposición invisible en una decisión revisable, y el día que el producto la contradiga alguien sabrá exactamente qué se está rompiendo y por qué.

⚔️ Convierte un singleton en una familia de instancias
  1. Localiza en tu aplicación un slice con singleton implícito y enumera las cuatro señales de la sección primera que aparecen en él.
  2. Decide si su clave debe ser de dominio o sintética y justifica la elección respondiendo a si dos instancias sobre el mismo registro tienen sentido en tu producto.
  3. Reescribe el slice indexado por clave con apertura idempotente y con todas las acciones portando la clave; comprueba que una acción tardía de una instancia cerrada no resucita nada.
  4. Abre dos instancias a la vez, instrumenta la función de resultado de un selector derivado con un contador y demuestra el golpeo de caché; después resuélvelo y verifica que las recomputaciones dejan de crecer.
  5. Define la política de baja para cada uno de los tres escenarios —desmontaje, navegación temporal y petición en vuelo— y muestra que el número de claves vivas vuelve a su nivel base tras veinte aperturas y cierres.
  6. Para cada campo de la instancia, argumenta si necesita el store o le bastaría el estado local del componente, y mueve al menos uno.