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

Estado derivado guardado: calcular una vez y arrepentirse siempre

El disfraz más elegante de la duplicación: un valor que es función pura de otros valores y que alguien decidió calcular una vez y guardar en una variable de estado. Esta lección explica por qué guardar un derivado convierte una relación permanente en una fotografía, por qué toda fotografía envejece y por qué el remedio habitual —recalcular en cada punto de mutación— empeora el problema en lugar de resolverlo. Recorre las cuatro formas del antipatrón, incluida la más engañosa de todas, la que se esconde dentro de un efecto; explica la diferencia real entre derivar y memoizar; y da el criterio para los pocos casos en los que materializar un derivado es correcto: el precio congelado, la agregación cara y la frontera de red.

⏱ 16 min

La lección anterior dejó una idea afilada: cuando un hecho tiene dos custodios, la divergencia es cuestión de tiempo. Esta lección aplica esa idea al caso más frecuente y también al más difícil de ver, porque viene envuelto en el lenguaje del rendimiento y de la buena ingeniería. Un valor derivado es cualquier cosa que sea función de otros valores: el total de un carrito a partir de sus líneas, el nombre completo a partir del nombre y el apellido, la lista visible a partir de la lista completa y el filtro, la validez de un formulario a partir de sus campos. Guardar ese valor en una variable de estado parece una optimización obvia, y en cierto sentido lo es: cambias trabajo por memoria, que es el intercambio más viejo de la informática. Lo que casi nunca se dice en voz alta es lo que estás cambiando en realidad, y no es trabajo por memoria. Es una relación permanente por una fotografía. La expresión que calcula el total es verdadera siempre, en cualquier instante, para cualquier contenido del carrito. El número que guardaste era verdadero exactamente una vez, y a partir del siguiente cambio pasa a ser una afirmación sobre un pasado que ya nadie recuerda.

🎯 Al terminar esta lección sabrás
  • Explicar la diferencia entre una relación permanente y una fotografía, y por qué guardar un derivado sustituye la primera por la segunda.
  • Identificar las cuatro formas del antipatrón, incluida la que se camufla dentro de un efecto de sincronización.
  • Distinguir con precisión derivar de memoizar, y saber por qué solo una de las dos preserva la corrección.
  • Aplicar el criterio de materialización legítima: valor congelado, agregación medida y frontera de red.
📸

Una fotografía, no una regla

Guardar el resultado sustituye una verdad permanente por un valor que fue cierto una vez y envejece desde entonces.

🧟

El parche que multiplica

Recalcular a mano en cada mutación no arregla nada: convierte un olvido posible en tantos olvidos posibles como escrituras existan.

🧮

Derivar no es memoizar

Memoizar acelera una expresión conservando su significado. Guardar en estado cambia el significado y por eso puede mentir.

La fotografía que envejece: por qué el derivado guardado no puede sobrevivir

Un derivado es una afirmación con cuantificador universal: para todo estado del carrito, el total es la suma de sus líneas. Cuando lo escribes como una expresión que se evalúa en cada lectura, esa afirmación se mantiene por construcción y no hay ningún instante del programa en el que pueda ser falsa. Cuando lo escribes como una variable de estado inicializada con el resultado de esa expresión, la afirmación se degrada a otra mucho más débil: en el momento en que se ejecutó aquella línea, el total valía eso. La diferencia entre ambas no es de eficiencia, es de fuerza lógica, y el sistema de tipos no puede notarla porque ambas cosas son un número.

// Antipatron: el derivado guardado. Correcto en el instante cero y falso despues.
const [lineas, setLineas] = useState<Linea[]>(inicial)
const [total, setTotal] = useState(() => lineas.reduce((s, l) => s + l.precio * l.cantidad, 0))

function anadirLinea(l: Linea) {
  setLineas(previas => [...previas, l])
  setTotal(t => t + l.precio * l.cantidad) // hay que acordarse, aqui y en todas partes
}

function aplicarDescuento(id: string, factor: number) {
  setLineas(previas => previas.map(l => (l.id === id ? { ...l, precio: l.precio * factor } : l)))
  // olvido tipico: el total ya miente y nada lo señala
}

Esa degradación tiene una consecuencia observable que sirve como prueba de laboratorio. Toma una aplicación con un derivado guardado, detén la ejecución en un punto arbitrario y compara el valor almacenado con el resultado de recalcular la expresión en ese instante. Si coinciden siempre, el valor almacenado es redundante y solo cuesta memoria y riesgo. Si difieren alguna vez, el valor almacenado es incorrecto. No hay una tercera posibilidad, y esa dicotomía es la forma más económica de zanjar la discusión con quien defienda el patrón: o sobra o miente, y en ambos casos la conclusión sobre qué hacer con él es la misma.

El detalle que hace este antipatrón especialmente pernicioso es que el primer parche parece funcionar. Alguien detecta el olvido en aplicarDescuento, añade el recálculo correspondiente y el bug desaparece. Lo que ha ocurrido en realidad es que el sistema ha empeorado: ahora hay dos sitios que deben recordar actualizar el total, y el siguiente que escriba una tercera mutación tendrá dos precedentes que imitar y ninguna garantía de que se dé cuenta. La probabilidad de fallo no baja con cada parche, sube, porque cada parche normaliza el patrón que causa el fallo.

Las cuatro formas, y la que va disfrazada de efecto

La primera forma es la explícita, la del ejemplo anterior: dos variables de estado, una derivada de la otra, y la sincronización repartida por las funciones de mutación. Es la más fácil de detectar porque las dos declaraciones suelen estar contiguas.

La segunda es la del campo derivado dentro del objeto de estado. En lugar de dos variables hay una sola con un campo que es función de sus hermanos: un objeto de usuario con nombre, apellido y también nombreCompleto, o un objeto de pedido con lineas y total. Esta es más difícil de ver porque el campo culpable está rodeado de campos legítimos y todo el conjunto se escribe de golpe, lo que da la falsa sensación de atomicidad.

La tercera es la del derivado transitivo, y es la que sobrevive más años en las bases de código maduras: no hay ninguna copia obvia porque el valor guardado no es la suma de nada, sino el resultado de una función que se llamó una vez con unos argumentos que ya no son los actuales. Un mensaje de error calculado a partir de un campo, un color calculado a partir de un umbral, una etiqueta calculada a partir de un rol. Aquí el envejecimiento es especialmente traicionero porque el valor sigue siendo plausible: nadie sospecha de una etiqueta que dice administrador, y hacen falta dos personas mirando la misma pantalla con permisos distintos para que alguien note que una de las dos ve una información que no le corresponde.

// Forma segunda: el campo derivado escondido entre campos legitimos.
type Usuario = {
  nombre: string
  apellido: string
  nombreCompleto: string // derivado: envejece en cuanto se edite cualquiera de los dos
}

// Forma tercera: el derivado transitivo, calculado una vez con argumentos ya caducados.
const [etiquetaRol, setEtiquetaRol] = useState(() => describirRol(usuario.rol))

La cuarta forma es la que enlaza con la lección siguiente y merece atención especial porque sus autores creen estar haciendo lo contrario de lo que hacen: el derivado que se mantiene con un efecto. Aquí no hay olvidos, porque el efecto observa las dependencias y recalcula cuando cambian, y por eso quien lo escribe siente que ha resuelto el problema mecánicamente. No lo ha resuelto: lo ha desplazado en el tiempo. El efecto se ejecuta después de que la vista se haya pintado con el valor viejo, de modo que ahora existe un intervalo perfectamente reproducible en el que el usuario ve un total que no corresponde a las líneas que tiene delante, seguido de un segundo repintado que lo corrige. Has cambiado un bug de corrección por un bug visual más un renderizado extra, y además has hecho el código dos veces más largo.

// Antipatron cuarto: el efecto que mantiene el derivado. Correcto un render tarde.
useEffect(() => {
  setTotal(lineas.reduce((s, l) => s + l.precio * l.cantidad, 0))
}, [lineas])

// Alternativa: la relacion se evalua en la lectura y no existe el intervalo mentiroso.
const total = lineas.reduce((s, l) => s + l.precio * l.cantidad, 0)
flowchart LR
F[fuente: lineas del carrito] --> C[calculo del total]
C --> G[total guardado como estado]
F --> M[mutacion posterior de las lineas]
M --> O[total obsoleto: la foto envejecio]
O --> P[parche: recalcular a mano en cada mutacion]
P --> M
F --> D[alternativa: derivar en cada lectura]
D --> V[valor coherente por construccion]
style O fill:#f38ba8,color:#11111b
style V fill:#a6e3a1,color:#11111b
⚠️
El argumento del rendimiento casi nunca ha sido medido

La justificación universal del derivado guardado es evitar recalcular en cada render, y merece tomarse en serio el tiempo suficiente para comprobar que casi siempre es falsa. Recorrer un array de doscientos objetos y sumar un producto cuesta del orden de microsegundos en cualquier dispositivo fabricado en la última década, mientras que el presupuesto de un fotograma es de dieciséis milisegundos: estás optimizando una milésima parte del presupuesto y pagando por ello con corrección. El segundo problema del argumento es que compara mal. La alternativa al derivado guardado no es recalcular ciegamente, es recalcular o memoizar, y memoizar te da el mismo ahorro sin ninguno de los riesgos. El tercer problema es el más incómodo: en la inmensa mayoría de los casos donde se invoca este argumento, nadie ha abierto un perfilador. Si lo abres, descubrirás que el coste dominante de tu render está en otro sitio —en el número de nodos, en un efecto en cascada, en una comparación por identidad que falla— y que el cálculo derivado no aparece siquiera en la lista. La regla operativa es simple y no admite excepciones cómodas: si no tienes una medición, no tienes un argumento de rendimiento, tienes una intuición, y las intuiciones sobre rendimiento son sistemáticamente erróneas en sistemas con caches, compiladores optimizadores y planificadores de fotogramas.

Derivar, memoizar y materializar: tres cosas distintas

Es imprescindible separar tres operaciones que la conversación cotidiana mezcla. Derivar significa expresar el valor como función de sus fuentes y evaluarlo en el punto de lectura; el valor no existe entre lecturas y por tanto no puede estar obsoleto. Memoizar significa conservar el resultado de una evaluación anterior junto con la identidad de las entradas que la produjeron, de modo que si las entradas no han cambiado se reutilice el resultado; la clave es que la caducidad está atada a las entradas, así que el valor devuelto siempre corresponde a las fuentes actuales. Materializar significa guardar el resultado como un hecho independiente, sin vínculo con las fuentes; a partir de ese momento las fuentes pueden cambiar y el valor no se entera.

La confusión práctica entre memoizar y materializar es la que produce este antipatrón, y se disuelve con una pregunta única: si las fuentes cambian y nadie hace nada, ¿qué devuelve la siguiente lectura? Si devuelve el valor nuevo, memoizaste. Si devuelve el viejo, materializaste. Todo lo demás —cuánta memoria ocupa, cuántas veces se ejecuta la función, si hay un array de dependencias— es secundario frente a esa distinción, porque solo ella determina si el programa puede mentir.

// Derivar: sin cache, siempre correcto.
const visibles = items.filter(coincideConFiltro)

// Memoizar: con cache atada a las entradas, sigue siendo siempre correcto.
const visibles = useMemo(() => items.filter(coincideConFiltro), [items, filtro])

// Materializar: sin vinculo con las entradas, correcto solo hasta el proximo cambio.
const [visibles, setVisibles] = useState(() => items.filter(coincideConFiltro))

Hay una tercera confusión que conviene desactivar antes de seguir, porque cada cierto tiempo reaparece con ropa nueva: la idea de que memoizar es una forma suave del antipatrón y que por tanto también hay que evitarla. No lo es, y el motivo es exactamente el mismo criterio de la caducidad. Una memoización correcta es transparente en el sentido técnico del término: puedes eliminarla del programa y el comportamiento observable no cambia, solo cambia el tiempo. Esa transparencia es la propiedad que la hace segura, y es la que el derivado materializado no tiene, porque eliminarlo sí cambia lo que el usuario ve. Cuando dudes de si una optimización es legítima, aplícale la prueba de la eliminación: si borrarla altera el resultado, no era una optimización, era una decisión de diseño disfrazada de optimización.

Existe además una jerarquía práctica que conviene seguir en ese orden y sin saltarse escalones: primero deriva, y quédate ahí salvo que una medición demuestre lo contrario; si la medición lo demuestra, memoiza, comprobando que las entradas son estables por identidad, porque una dependencia que se recrea en cada render convierte la memoización en un adorno caro; y solo si tras memoizar sigue habiendo un problema real, plantéate materializar, sabiendo que estás asumiendo una obligación de invalidación que alguien tendrá que mantener para siempre.

Cuándo materializar es lo correcto

Hay tres situaciones en las que guardar el resultado es la decisión correcta y confundirlas con el antipatrón produce refactores que rompen el dominio. La primera es el valor congelado, que ya apareció en la lección anterior como instantánea histórica: el total de una factura emitida no es el total actual de sus líneas, es el total que se acordó, y debe sobrevivir a cualquier cambio posterior de precios. Aquí no estás guardando un derivado, estás registrando un hecho nuevo, y la señal inequívoca es que el valor tiene su propia fecha y su propia autoridad.

La segunda es la agregación cara sobre volúmenes grandes, y aquí la palabra clave es medida. Un cálculo que recorre cien mil registros, hace una ordenación completa o construye un índice puede justificar la materialización, pero solo si viene acompañado de un mecanismo explícito de invalidación y de una prueba que verifique la coherencia entre el valor guardado y el recalculado. Sin ese mecanismo y sin esa prueba, no has materializado: has duplicado.

La tercera es la frontera de red. Cuando el derivado se calcula en el servidor y llega ya calculado, guardarlo en el cliente no es materializar un derivado local: es almacenar la respuesta de una fuente remota, con lo cual el problema deja de pertenecer a esta lección y pasa a ser un problema de cache, con su caducidad y su invalidación. La distinción importa porque el remedio es completamente distinto: no se resuelve derivando en el cliente, se resuelve tratando el dato como lo que es, estado de servidor.

// Materializacion legitima: la invalidacion es explicita y la coherencia es comprobable.
type Indice = { version: number; porCategoria: Map<string, Producto[]> }

function reconstruirIndice(productos: Producto[], version: number): Indice {
  const porCategoria = new Map<string, Producto[]>()
  for (const p of productos) porCategoria.set(p.categoria, [...(porCategoria.get(p.categoria) ?? []), p])
  return { version, porCategoria } // la version ata el indice a la fuente que lo genero
}

// La comprobacion que convierte una promesa en una garantia verificable.
test('el indice materializado coincide con el recalculado', () => {
  expect(indice.version).toBe(versionActualDeProductos)
})

Ese ejemplo contiene el resumen de toda la lección en una línea: el campo de versión. Materializar sin un vínculo comprobable con la fuente es duplicar; materializar con él es construir una vista materializada honesta, que sabe a qué estado de la fuente corresponde y puede declararse caducada. La diferencia entre las dos prácticas no está en el rendimiento que consiguen, que es idéntico, sino en si el sistema puede detectar por sí mismo que el valor guardado ha dejado de ser cierto.

Materializar es aceptar una obligación de invalidación, y esa obligación es el verdadero coste

Detrás de este antipatrón hay una asimetría que conviene ver una vez para no olvidarla nunca. Cuando escribes una derivación, estás declarando una ley: el total es la suma. Esa ley se mantiene sola porque no hay ningún estado que pueda contradecirla; el valor no existe entre lecturas y por tanto no hay nada que pueda quedarse atrás. Cuando materializas, la ley desaparece y en su lugar queda una obligación: alguien debe actualizar este número cada vez que cambien sus fuentes. La diferencia crucial es quién la cumple. Una ley la cumple el lenguaje; una obligación la cumplen las personas, y las personas cambian de equipo, escriben la tercera mutación un martes por la tarde y no leen el comentario que dejaste. La bibliografía de bases de datos lleva medio siglo diciendo esto con otras palabras: una vista materializada es siempre más rápida y siempre más peligrosa que una vista, y por eso todo sistema serio que las ofrece invierte la mayor parte de su complejidad no en calcularlas, sino en invalidarlas correctamente. Cuando materializas un derivado en el cliente estás construyendo una vista materializada sin ninguna de las garantías que un motor de datos te habría dado: sin registro de transacciones, sin disparadores de invalidación, sin refresco incremental y sin nadie que compruebe la coherencia. El resultado es predecible y es exactamente lo que ves en producción: no un fallo estruendoso, sino un número que en una de cada diez sesiones está un paso por detrás de la realidad, que ningún test detecta porque los tests recorren el camino feliz, y que se reporta como un bug intermitente e irreproducible durante meses. El precio de la materialización no se paga en el momento de escribirla. Se paga en cada cambio futuro que alguien haga sin saber que existía.

⚔️ Erradica los derivados guardados
  1. Recorre tus objetos de estado y marca todo campo cuyo valor podrías reconstruir con una expresión sobre sus hermanos.
  2. Para cada candidato, cuenta cuántas funciones de mutación tendrían que acordarse de actualizarlo y comprueba si todas lo hacen.
  3. Busca los efectos cuyo cuerpo consista únicamente en escribir un estado a partir de otro y reescribe uno de ellos como derivación directa.
  4. Aplica la pregunta de la caducidad a cada valor cacheado de tu aplicación y clasifícalo como memoizado o materializado.
  5. Mide con un perfilador el coste real de una derivación que alguien decidió guardar por rendimiento, y compara la cifra con el presupuesto de fotograma.
  6. Para las materializaciones que decidas conservar, escribe la prueba que compara el valor guardado con el recalculado y déjala en la suite.