wandres.dev
ERRORES COMUNES · y cómo salir

Guardar datos derivados: el campo que se desincroniza

Un total, un contador, una lista filtrada o un nombre completo guardados como campos del estado son bombas de relojería: cada uno introduce un invariante que hay que restablecer en todas las acciones presentes y futuras, y basta que alguien olvide una para que el dato quede desincronizado sin que nada falle. Esta lección demuestra por qué la sincronización manual pierde siempre por razones estructurales, desarrolla la sustitución por un selector memoizado con su análisis de coste, y establece la distinción decisiva que separa el dato derivado del hecho histórico: no todo lo que parece calculable debe calcularse, porque algunos valores son registros de un momento y no funciones del presente.

⏱ 18 min

Hay un campo en casi todos los stores del mundo que no debería existir. Se llama total, o numeroDePendientes, o tareasFiltradas, o esValido, o nombreCompleto, y su rasgo común es que su valor no es información nueva: es una función de otros campos que ya están ahí. Guardarlo parece prudente —se calcula una vez y se lee muchas— y resulta ser lo contrario, porque en el momento en que lo escribes en el estado dejas de tener un dato y pasas a tener un invariante: la obligación de que ese campo coincida siempre con el resultado de recalcularlo. Los invariantes no se mantienen solos. Se mantienen a mano, en cada acción que toca las entradas, incluidas las que aún no se han escrito y las que escribirá dentro de ocho meses alguien que no sabe que ese campo existe. Y como una desincronización no lanza ningún error, solo muestra un número equivocado, el fallo llega al usuario antes que a ti.

🎯 Al terminar esta lección sabrás
  • Identificar los campos derivados de un estado y el invariante implícito que cada uno introduce.
  • Explicar por qué la sincronización manual falla por razones estructurales y no por descuido.
  • Sustituir un campo derivado por un selector memoizado y evaluar su coste real.
  • Distinguir el dato derivado del hecho histórico, que parece calculable pero debe almacenarse.

El invariante que nadie firmó

Observa lo que ocurre exactamente cuando añades un campo derivado. Antes tenías un estado con una lista de artículos y una función mental que decía que el total es la suma de sus precios. Esa función era una verdad tautológica: no podía estar equivocada porque no estaba almacenada en ningún sitio, se evaluaba cuando hacía falta. Al escribir total en el estado conviertes esa tautología en una afirmación que puede ser falsa, y a partir de ese instante tu programa tiene dos fuentes que dicen cosas sobre lo mismo y que pueden discrepar.

// Estado con un campo derivado: total debe cumplir un invariante.
interface Carrito {
  articulos: { id: string; precio: number; cantidad: number }[]
  total: number // invariante: igual a la suma de precio por cantidad
}

// Cada reducer que toca articulos debe restablecer el invariante.
reducers: {
  articuloAgregado(e, a) {
    e.articulos.push(a.payload)
    e.total = recalcular(e.articulos)
  },
  articuloQuitado(e, a) {
    e.articulos = e.articulos.filter((x) => x.id !== a.payload)
    e.total = recalcular(e.articulos)
  },
  cantidadCambiada(e, a) {
    const x = e.articulos.find((i) => i.id === a.payload.id)
    if (x) x.cantidad = a.payload.cantidad
    // olvido: total queda desincronizado y nada falla
  },
}

El tercer reducer es el que siempre aparece, y su ausencia de la línea de recálculo no es un despiste individual sino la consecuencia previsible de la forma del problema. La obligación de restablecer el invariante no está codificada en ninguna parte: no la impone el tipo, no la comprueba el compilador, no la recuerda el nombre de la función. Vive en la cabeza de quien escribió el slice, y esa cabeza no está presente cuando otra persona añade el cuarto reducer seis meses más tarde. El número de puntos donde el invariante puede romperse crece con cada acción nueva, y el número de personas que saben que hay que mantenerlo decrece con cada rotación del equipo. Es una carrera que la sincronización manual pierde siempre, y la pierde en silencio.

⚠️
La desincronización no lanza: informa mal

Un estado corrupto por mutación acaba manifestándose como una interfaz congelada o un historial incoherente, y eso al menos llama la atención. Un campo derivado desincronizado no produce ningún síntoma técnico: la aplicación funciona, no hay excepciones, las pruebas pasan y la interfaz muestra un número. Lo único que ocurre es que ese número es falso. Si el campo es un contador de notificaciones, el usuario ve un tres donde hay dos. Si es un total de carrito, le cobras de más o de menos. Si es una bandera de validez, le dejas enviar un formulario inválido o le bloqueas uno correcto. Los tres son fallos funcionales de primera magnitud que ningún mecanismo automático puede detectar, porque desde el punto de vista del programa el valor almacenado es tan legítimo como cualquier otro. Este es el motivo real por el que la regla se enuncia como prohibición y no como recomendación.

flowchart TD
A[accion que toca las entradas] --> B[el reducer actualiza la fuente]
B --> C[alguien recuerda recalcular el derivado]
C -->|si| D[invariante intacto por ahora]
C -->|no| E[dos verdades en conflicto]
E --> F[la interfaz muestra el valor viejo]
F --> G[ninguna alarma se dispara]
style E fill:#f38ba8,color:#11111b
style G fill:#f38ba8,color:#11111b
style D fill:#f9e2af,color:#11111b

La sustitución: derivar al leer

El remedio es estructural y consiste en eliminar la posibilidad del error en lugar de vigilarla. Si el total no está en el estado, no puede desincronizarse; si se calcula en el momento de leerlo a partir de la única fuente que existe, la coincidencia con esa fuente deja de ser un invariante que mantener y vuelve a ser una tautología. Esa es la función de un selector: trasladar el cálculo del momento de la escritura al momento de la lectura, donde solo hay un punto en lugar de muchos.

// El estado guarda solo lo que no se puede deducir.
interface Carrito {
  articulos: { id: string; precio: number; cantidad: number }[]
}

const selectArticulos = (s: RootState) => s.carrito.articulos

const selectTotal = createSelector([selectArticulos], (articulos) =>
  articulos.reduce((acc, a) => acc + a.precio * a.cantidad, 0),
)

// Derivaciones compuestas: cada una depende de la anterior, no del estado.
const selectHayEnvioGratis = createSelector([selectTotal], (t) => t >= 50)

La objeción inmediata es el coste: si el selector se ejecuta tras cada despacho, ¿no estás recalculando constantemente algo que antes calculabas una vez? La memoización responde exactamente a eso, pero conviene entender qué garantiza y qué no. Un selector memoizado guarda sus entradas y su último resultado; si las entradas son idénticas por referencia, devuelve el resultado anterior sin recalcular y, lo que a menudo importa más, sin crear una referencia nueva. Eso significa dos cosas: que el coste computacional se paga solo cuando la fuente cambia de verdad, y que el resultado es estable para la comparación que decide los renders. La memoización no es una optimización opcional que añades si va lento; es lo que hace que derivar al leer tenga el mismo perfil de coste que derivar al escribir, sin ninguno de sus riesgos.

🧮

Suma, cuenta, promedio

Agregaciones sobre una colección que ya tienes. Nunca se guardan: se calculan con un reduce en un selector memoizado.

🔎

Listas filtradas u ordenadas

Vistas de una colección según un criterio que también está en el estado. Guardarlas duplica la colección y multiplica los invariantes.

Banderas de validez

Un campo esValido es el peor caso: se desincroniza sin ruido y su valor decide si el usuario puede continuar.

🧷

Concatenaciones y formatos

Nombre completo, iniciales, etiquetas formateadas o fechas legibles pertenecen a la capa de presentación, no al estado.

Hay un beneficio secundario de esta sustitución que compensa por sí solo el trabajo: el estado se vuelve mucho más pequeño y, con ello, mucho más fácil de persistir, de serializar y de migrar entre versiones. Cada campo derivado que guardas es un campo que hay que incluir en el estado persistido, versionar cuando cambie su fórmula y migrar cuando la lógica que lo calculaba se corrija, con el agravante de que un usuario que rehidrate un estado antiguo recibirá un valor calculado con reglas obsoletas y no habrá forma de distinguirlo de uno correcto. Los datos derivados fuera del estado se recalculan solos tras la rehidratación, con la fórmula vigente, sin migración ninguna.

Merece la pena señalar la variante organizativa de este mismo error, porque en 2026 es más frecuente que la original: copiar en un slice lo que ya vive en la caché de una librería de datos del servidor. Un componente recibe la respuesta de una consulta y despacha una acción para guardarla en el store, con la intención de tenerla a mano. Acaba de crear un campo derivado cuya fuente es una caché que se invalida, se refresca en segundo plano y se recolecta sola, de modo que el invariante que ha introducido no depende siquiera de acciones propias: depende de eventos de red que ocurren fuera de su control. Es la desincronización garantizada.

Lo que parece derivado y no lo es

Aquí está la distinción que separa a quien aplica la regla de quien la entiende, y es la que evita el sobrecorrección más costosa de este nivel. No todo valor que puede calcularse a partir del estado actual debe calcularse. Considera el precio de una línea de pedido ya confirmado. Podrías obtenerlo multiplicando el precio actual del producto por la cantidad, y sería un cálculo válido hasta el día en que el producto cambie de precio y todos los pedidos históricos se reescriban solos. Ese precio no es un dato derivado: es un hecho, el registro de lo que costó ese artículo en el instante de la compra, y su coincidencia con el catálogo actual era una casualidad temporal, no una definición.

El criterio para distinguirlos es la pregunta por el tiempo. Un dato derivado es una función del estado presente, y por tanto debe cambiar cuando el presente cambie: si el carrito cambia, el total tiene que cambiar. Un hecho histórico es una función del estado en un instante pasado, y por tanto no debe cambiar aunque el presente cambie: si el catálogo sube de precio, el pedido de ayer sigue costando lo que costó. Guardar un dato derivado es un error porque congela algo que debía seguir la fuente; recalcular un hecho histórico es un error simétrico y peor, porque falsifica el pasado. La misma pregunta ordena los casos ambiguos: el tipo de cambio aplicado a una transacción, la tarifa vigente cuando se firmó el contrato, el nombre del usuario que aprobó una acción, la comisión pactada. Todos parecen deducibles y ninguno lo es.

Cuándo derivar al leer no basta

La regla tiene excepciones legítimas y merecen enunciarse con precisión, porque quien las desconoce acaba defendiendo la pureza contra la evidencia. La primera es el coste demostrado: una derivación que recorre decenas de miles de elementos, construye un índice invertido o resuelve un cálculo cuadrático no se paga con memoización cuando sus entradas cambian a menudo, porque la memoización solo evita el recálculo mientras la fuente no se toca. La segunda es la derivación que forma parte de la estructura de datos misma: el array de identificadores que mantiene createEntityAdapter junto al diccionario de entidades es, técnicamente, un dato derivado, y sin embargo está almacenado porque es el índice que da orden y acceso constante a la colección.

Esa segunda excepción revela el criterio que las gobierna a ambas, y no es el coste sino la custodia. Un dato derivado puede almacenarse cuando existe un único propietario que lo actualiza de forma atómica junto a su fuente, y cuando resulta imposible modificar la fuente sin pasar por él. El adaptador cumple las dos condiciones: sus reducers son los únicos que tocan las entidades y cada uno actualiza el índice en la misma operación, así que el invariante no depende de que nadie lo olvide, depende de que nadie pueda saltárselo. Un campo total escrito a mano en un slice abierto no cumple ninguna de las dos, y ahí está toda la diferencia entre una desnormalización disciplinada y una bomba de relojería.

// Excepcion legitima: el propietario mantiene fuente e indice a la vez.
articulosSlice.reducers = {
  articuloAgregado(e, a) {
    e.entidades[a.payload.id] = a.payload
    e.ids.push(a.payload.id) // el invariante no se puede eludir desde fuera
  },
}

// Selector con argumento: una fabrica por consumidor evita que dos
// componentes con filtros distintos se invaliden mutuamente la cache.
const makeSelectPorEstado = () =>
  createSelector(
    [selectArticulos, (_: RootState, estado: string) => estado],
    (articulos, estado) => articulos.filter((a) => a.estado === estado),
  )

Queda una trampa práctica que aparece en cuanto se adopta la memoización con convicción: un selector memoizado guarda un único resultado, así que si dos componentes lo llaman con argumentos distintos, cada llamada expulsa la caché de la otra y el selector deja de memoizar sin dejar de parecer memoizado. La solución es una fábrica por consumidor, creada una vez por instancia del componente. Reconocerlo importa porque produce el peor de los mundos: el coste de mantener una caché y ninguno de sus beneficios, con la agravante de que el código sigue teniendo el aspecto correcto y por tanto nadie sospecha de él.

Normalizar el estado es aplicar la forma normal a la memoria de tu aplicación

Este error tiene un nombre anterior a Redux, a JavaScript y al frontend entero, y reconocerlo ahorra tener que redescubrir sus consecuencias una por una. Lo que ocurre cuando guardas un total junto a los artículos que lo componen es exactamente una dependencia funcional almacenada, la situación que las formas normales de la teoría relacional llevan medio siglo prohibiendo por una razón que nunca fue el ahorro de espacio: cuando un mismo hecho está representado dos veces, toda operación de actualización tiene que modificar ambas representaciones o dejar la base en un estado inconsistente, y ninguna operación puede garantizarlo por sí sola. Codd llamó a esto anomalía de actualización y observó que no se resuelve con disciplina sino con diseño, porque la disciplina escala con el número de programadores y el diseño no necesita escalar. El store de una aplicación es una base de datos en memoria con un modelo de escritura peculiar, y hereda el teorema completo: si un valor se puede deducir de otros, almacenarlo introduce una anomalía cuya probabilidad de manifestarse tiende a uno conforme crece el número de operaciones. Pero la teoría relacional trae también la contrapartida que evita el celo excesivo, y es la misma que acabamos de formular con el tiempo: la desnormalización deja de ser un pecado cuando el valor almacenado no es una copia del presente sino el registro de un instante, porque entonces no hay dos representaciones de un hecho sino dos hechos distintos que casualmente coinciden hoy. Distinguir ambos casos es la competencia de fondo, y no se adquiere memorizando qué campos guardar: se adquiere preguntando, ante cada valor, si su verdad depende del estado actual o de un momento que ya pasó. La primera pertenece a un selector. La segunda pertenece al estado, y borrarla es perder información que no se puede reconstruir.

⚔️ Elimina los invariantes de tu estado
  1. Recorre tus slices y marca todo campo cuyo valor podrías recalcular a partir de otros campos del mismo estado; ese es tu inventario de invariantes.
  2. Para cada uno, cuenta cuántos reducers deberían actualizarlo y cuántos lo hacen realmente; la diferencia es tu deuda actual.
  3. Elige el campo con más lectores y sustitúyelo por un selector memoizado, comprobando que la referencia devuelta se conserva entre despachos que no lo afectan.
  4. Provoca la desincronización a propósito en el campo que aún no has migrado y observa que ninguna prueba falla; escribe la prueba que sí lo detectaría.
  5. Busca los datos del servidor copiados a mano en slices y elimina la copia, dejando que la caché sea la única fuente.
  6. Recorre los campos que parecen derivados y aplica la pregunta del tiempo: separa los que deben seguir al presente de los que registran un instante, y documenta por qué en los casos dudosos.