wandres.dev
PATRONES AVANZADOS · undo, optimista, entity

Actualizaciones optimistas: aplicar antes de saber y revertir con precisión

Una actualización optimista aplica el cambio en el instante de la pulsación y apuesta a que el servidor confirmará. La apuesta gana casi siempre y la interfaz se vuelve instantánea; el problema es la minoría de veces que pierde, porque revertir no es restaurar la instantánea anterior sino deshacer un cambio concreto sin borrar todo lo que ocurrió mientras la petición viajaba. Esta lección construye el parche invertible como unidad de reversión, distingue los tres finales posibles de una petición, resuelve la identidad temporal de las entidades creadas sin identificador del servidor, y termina con el criterio para decidir qué operaciones merecen optimismo y cuáles jamás deberían tenerlo.

⏱ 18 min

La lección anterior guardaba el pasado para poder volver a él por voluntad del usuario. Esta lo guarda para poder volver a él por culpa de la red, y la diferencia entre ambos casos es lo que hace difícil el segundo. Una actualización optimista consiste en aceptar que la interfaz no debe esperar a la confirmación del servidor para mostrar el resultado: aplicas el cambio en el mismo fotograma de la pulsación, envías la petición, y solo si falla revocas lo que habías mostrado. La apuesta es sensata porque las peticiones que un usuario inicia deliberadamente se cumplen la inmensa mayoría de las veces, y el premio es una interfaz sin latencia percibida. Pero el diseño no lo determina el caso feliz, que es trivial, sino el minoritario: hay que revertir un cambio concreto en un estado que ya no es el que había cuando lo aplicaste, y hacerlo sin destruir el trabajo que el usuario hizo entretanto.

🎯 Al terminar esta lección sabrás
  • Formular la actualización optimista como una apuesta con contrato explícito de reversión.
  • Explicar por qué restaurar una instantánea es incorrecto y qué es un parche invertible.
  • Resolver la identidad temporal de las entidades creadas antes de conocer su identificador real.
  • Decidir qué operaciones admiten optimismo y cuáles deben esperar siempre a la confirmación.

La apuesta y su contrato

Toda actualización optimista es una predicción: el cliente calcula el estado que cree que tendrá el servidor cuando la operación termine, y lo muestra ya. Que esa predicción sea legítima depende de una condición que conviene enunciar sin ambigüedad, porque es la que separa el patrón de la temeridad: el cliente debe poder computar el resultado con la información que tiene, y la operación debe tener una probabilidad de éxito muy alta. Marcar un mensaje como leído, dar a me gusta, renombrar un elemento o reordenar una lista cumplen ambas; cobrar una tarjeta, reservar el último asiento de un vuelo o aplicar un cupón cuyo descuento calcula el servidor no cumplen ninguna de las dos, porque el resultado no es predecible localmente y el fallo no es raro.

El contrato tiene tres finales y los tres deben estar escritos antes de aplicar nada. El primero es la confirmación, en la que el servidor devuelve el estado real y el cliente reconcilia su predicción con él —que no siempre coinciden, aunque la operación haya tenido éxito—. El segundo es el rechazo, en el que hay que revocar el cambio y decírselo al usuario de forma que entienda qué se ha deshecho. El tercero, el que casi todo el mundo olvida, es la indeterminación: la petición no responde, el navegador se queda sin red, la pestaña se cierra. Ese tercer final no admite una respuesta automática correcta, y la decisión de qué hacer con él —reintentar, encolar, revertir tras un tiempo límite— es de producto, no de infraestructura.

Aplicar

El cambio se refleja en el mismo fotograma de la interacción. La interfaz deja de tener latencia percibida porque no espera a nadie.

Confirmar

Llega la respuesta y sustituye la predicción por la verdad del servidor, que puede diferir en campos que el cliente no podía calcular.

↩️

Revocar

El fallo deshace exactamente el cambio aplicado, no el estado entero, y comunica al usuario qué se ha revertido y por qué.

🌫️

Indeterminar

Ni respuesta ni error. Es el final que ninguna librería resuelve por ti y el que decide si tu aplicación es fiable fuera del laboratorio.

Revertir un cambio, no restaurar un estado

La implementación ingenua guarda una copia del estado antes de aplicar el cambio y, si la petición falla, la restaura. Es incorrecta, y la razón es exclusivamente temporal: entre la aplicación y el fallo transcurren cientos de milisegundos durante los cuales el usuario ha seguido usando la aplicación y el servidor ha podido enviar datos nuevos. Restaurar la instantánea revierte también todo eso, de modo que el fallo de una petición borra trabajo que no tenía nada que ver con ella. El síntoma es característico y desconcertante: el usuario marca un elemento, escribe en otro campo, falla la red, y lo que había escrito desaparece.

Lo correcto es tratar la reversión como la aplicación del inverso de un cambio concreto, no como un salto en el tiempo. Es la misma distinción que en control de versiones separa revertir un commit de restablecer la rama a un punto anterior: lo primero compone con el trabajo posterior, lo segundo lo aniquila. En la práctica esto significa registrar el parche que la actualización optimista produjo —qué rutas del estado tocó y qué valor tenían— y aplicar su inversa si hace falta. RTK Query lo expone directamente con el resultado del parche y su método undo, e Immer lo ofrece de forma general con los parches inversos que puede emitir al producir cualquier estado.

// RTK Query: el parche optimista y su reversion precisa.
const api = createApi({
  endpoints: (build) => ({
    marcarLeido: build.mutation<void, { id: string }>({
      query: ({ id }) => ({ url: `/mensajes/${id}/leido`, method: 'POST' }),
      async onQueryStarted({ id }, { dispatch, queryFulfilled }) {
        const parche = dispatch(
          api.util.updateQueryData('listarMensajes', undefined, (borrador) => {
            const m = borrador.find((x) => x.id === id)
            if (m) m.leido = true
          }),
        )
        try {
          await queryFulfilled
        } catch {
          parche.undo() // revierte SOLO esta mutacion, no el estado entero
        }
      },
    }),
  }),
})

Cuando no usas una capa de datos que te dé el parche hecho, la pieza que necesitas es la emisión de parches inversos de la librería de inmutabilidad. Al producir un estado nuevo puedes pedir a la vez la lista de operaciones que lo generaron y la lista que lo desharía, guardar la segunda asociada al identificador de la operación en vuelo, y aplicarla si la petición falla. La ventaja sobre la instantánea es exactamente la misma que antes y ahora es explícita en el código: lo que guardas no es un estado, es una diferencia.

import { produceWithPatches, applyPatches, enablePatches } from 'immer'
enablePatches()

const [siguiente, parches, inversos] = produceWithPatches(estado, (b) => {
  b.mensajes[id].leido = true
})
// Guardar `inversos` junto al identificador de la operacion en vuelo.
// Al fallar: applyPatches(estadoActual, inversos) — compone con lo ocurrido despues.
ℹ️
Las dos operaciones que se revierten solas y la que no

Un cambio idempotente y conmutativo se revierte sin drama, porque su inverso no depende de lo que pasara mientras tanto: marcar como leído, activar un interruptor, fijar un valor. Un cambio relativo es más delicado pero sigue siendo tratable si el inverso también es relativo: incrementar un contador se revierte decrementándolo, y esa reversión sigue siendo correcta aunque otros hayan incrementado entretanto, cosa que no ocurriría si restaurases el valor absoluto que leíste. El caso duro es la creación de una entidad, porque su inverso implica identificar qué entidad borrar y esa identidad todavía no existía cuando aplicaste el cambio. Es exactamente el problema de la sección siguiente.

Identidad temporal: crear antes de que exista

Cuando el usuario crea algo, la interfaz debe mostrarlo de inmediato, pero el identificador canónico lo asigna el servidor. El cliente necesita entonces una identidad provisional que le permita renderizar, referenciar y eventualmente borrar la entidad antes de saber cómo se llama de verdad. La solución estándar es generar un identificador temporal en el cliente, insertar la entidad con él y marcarla como pendiente, y al confirmar sustituir la identidad temporal por la definitiva en la colección y en todo lo que apunte a ella.

Esa sustitución es la parte que se subestima. Si el estado está normalizado, la entidad vive en un mapa por identificador y puede haber referencias a ella desde otras entidades, desde una selección activa, desde una ruta de navegación o desde otra operación optimista en vuelo. Cambiar la clave obliga a reescribir todas esas referencias de forma atómica; olvidarse de una produce un puntero colgante que rompe al deshacer o al borrar. La alternativa que muchos equipos acaban adoptando es más limpia: generar el identificador definitivo en el cliente con un formato que el servidor acepte y respete, de modo que la entidad nazca con su nombre final y la reconciliación se limite a completar campos.

flowchart TD
U[usuario pulsa] --> A[aplicar parche optimista]
A --> R[render inmediato]
A --> P[enviar peticion]
P --> OK{respuesta}
OK -->|exito| C[reconciliar con datos del servidor]
OK -->|error| D[aplicar parche inverso y avisar]
OK -->|sin respuesta| T[decidir: reintentar encolar o revertir]
style C fill:#a6e3a1,color:#11111b
style D fill:#f38ba8,color:#11111b
style T fill:#f9e2af,color:#11111b
// Identidad definitiva generada en el cliente: la reconciliacion se vuelve trivial.
const id = crypto.randomUUID()
dispatch(tareas.anadida({ id, titulo, pendiente: true, operacion: id }))
try {
  const real = await api.crear({ id, titulo })
  dispatch(tareas.confirmada({ id, cambios: real })) // reconcilia, no confirma
} catch {
  dispatch(tareas.borrada(id))                       // el inverso conoce a quien borrar
}

Queda un detalle que distingue una implementación cuidadosa de una descuidada: la confirmación no es una operación vacía. Aunque la petición tenga éxito, el estado que devuelve el servidor puede diferir de tu predicción en campos que el cliente no podía conocer —marcas de tiempo, contadores agregados, números de versión, valores normalizados o truncados—. Descartar la respuesta porque la operación fue bien deja la interfaz mostrando una predicción que ya nadie va a corregir, y esa divergencia silenciosa sobrevive hasta la siguiente recarga. La regla es que el éxito reconcilia, no confirma.

Concurrencia y el criterio para no ser optimista

Antes de la concurrencia hay una decisión de representación que condiciona todo lo demás: cómo distingue tu estado un dato confirmado de uno apostado. La forma pobre es no distinguirlos, y produce aplicaciones que persisten predicciones en disco, las envían a otras pestañas y las usan como base para calcular la petición siguiente. La forma correcta es marcar la entidad o el campo como pendiente mientras haya una operación en vuelo sobre él, con el identificador de esa operación, de modo que la interfaz pueda atenuarlo, impedir acciones destructivas sobre él y saber a quién pertenece cada parche. Ese registro de operaciones en vuelo es además el índice que la reversión necesita para ser precisa.

📝
Lo pendiente debe ser visible sin ser ruidoso

Un cambio optimista que se muestra idéntico a uno confirmado le está diciendo al usuario algo más fuerte de lo que sabes. Un cambio optimista que se muestra con un indicador de carga destruye la ventaja entera del patrón, porque devuelve la espera que querías eliminar. El punto medio que funciona es una atenuación discreta que no bloquea nada y que la mayoría de los usuarios nunca nota, porque la mayoría de las veces dura ciento cincuenta milisegundos; su función no es informar en el caso feliz sino dar contexto en el caso raro, para que el mensaje de fallo caiga sobre algo que el usuario ya veía como provisional.

Varias operaciones optimistas sobre la misma entidad se solapan con normalidad, y ahí aparece el fallo más difícil de reproducir de todo el patrón. Si el usuario pulsa dos veces sobre el mismo interruptor, hay dos peticiones en vuelo y dos parches aplicados; si la primera falla y la segunda tiene éxito, revertir la primera restaura un valor que la segunda ya había superado. La respuesta correcta no es evitar el solapamiento sino ordenarlo: revierte solo si el parche que vas a deshacer sigue siendo el último aplicado sobre esa entidad, y descarta la reversión si otro cambio posterior ya la ha dejado obsoleta. Cuando el orden importa de verdad, la solución robusta es serializar por entidad con una cola que garantice que sobre un mismo objetivo no hay nunca dos peticiones simultáneas.

Conviene además elegir con criterio la unidad de agrupación de las peticiones, porque el solapamiento se puede reducir en origen. Un interruptor que el usuario pulsa repetidamente no necesita una petición por pulsación: necesita que la interfaz responda a cada una y que la red reciba solo el estado final tras una breve espera. Aplicar el cambio optimista siempre y retrasar el envío es la combinación que da lo mejor de ambos mundos, y reduce el número de reversiones posibles a una por ráfaga en lugar de una por pulsación. La regla que ordena el reparto es simple: la interacción es inmediata, la red es agrupada, y entre ambas hay un único parche vivo por entidad.

⚠️
Optimismo indebido: las tres señales

No apliques el patrón si el resultado no lo puede calcular el cliente, porque entonces no estás prediciendo sino inventando. No lo apliques si el fallo es frecuente o previsible —conflictos de edición, límites de cuota, validaciones de negocio complejas—, porque una reversión frecuente es peor que una espera honesta. Y no lo apliques nunca si la reversión visual no revierte la consecuencia real: mostrar un pago como realizado y luego retirarlo no es una molestia de interfaz, es haber comunicado al usuario un hecho falso sobre su dinero. El optimismo se paga con confianza, y la confianza no tiene parche inverso.

La interfaz optimista es una interfaz que miente con método, y el método es todo lo que la separa del engaño

Merece la pena nombrar sin eufemismos lo que este patrón hace, porque solo así se entiende dónde está su verdadera dificultad. Una actualización optimista muestra al usuario un estado del mundo que todavía no es cierto. No es una aproximación ni una previsualización: es una afirmación sobre la realidad emitida antes de tener autoridad para emitirla, y su justificación es puramente estadística —será cierta casi siempre—. Aceptar eso obliga a reconocer que el cliente ha dejado de ser un espejo del servidor para convertirse en un modelo predictivo del servidor, con la carga epistemológica que eso arrastra: si predices, mantienes en tu estado dos categorías de hecho que antes se confundían, lo confirmado y lo apostado, y toda la corrección del sistema depende de que sepas en cada instante a cuál pertenece cada dato. La mayoría de los bugs difíciles del patrón no vienen de la reversión mal escrita, sino de haber perdido esa distinción: un dato apostado que se trata como confirmado y se guarda en disco, se envía a otra pestaña, se usa para calcular otra petición o se muestra en un lugar donde el usuario tomará una decisión irreversible. La disciplina que exige el optimismo no es técnica, es de honestidad representacional: marcar lo pendiente como pendiente, conservar el parche que permitiría retirarlo, y no dejar que una predicción cruce jamás la frontera hacia una capa que solo debería contener hechos. Y de ahí se sigue el criterio que gobierna cuándo usarlo, que ninguna librería puede tomar por ti: el optimismo es legítimo cuando el coste de equivocarse lo paga la interfaz, y es inaceptable cuando lo paga el usuario. Corregir una lista mal ordenada durante trescientos milisegundos no cuesta nada; retirar un mensaje enviado, un pago aceptado o una reserva confirmada cuesta exactamente aquello por lo que la gente usa tu producto. Toda la ingeniería de esta lección existe para que esa segunda categoría nunca ocurra por accidente.

⚔️ Haz optimista una mutación y rómpela a conciencia
  1. Elige una mutación idempotente de tu aplicación y hazla optimista con el parche y su reversión, sin guardar en ningún momento una instantánea del estado completo.
  2. Fuerza el fallo de la petición mientras el usuario edita otra parte de la pantalla y comprueba que la reversión no destruye ese trabajo; después implementa la versión con instantánea y observa el daño que produce.
  3. Añade una mutación de creación con identidad provisional, enumera todos los lugares del store que pueden referenciar la entidad nueva y escribe la sustitución de identificador de forma atómica.
  4. Provoca el solapamiento de dos peticiones sobre la misma entidad, con fallo en la primera y éxito en la segunda; implementa la comprobación que impide revertir un parche ya superado.
  5. Haz que el servidor devuelva en la respuesta un campo que tu predicción no podía calcular y verifica que la confirmación reconcilia en lugar de ignorar.
  6. Simula el tercer final: corta la red sin devolver error. Decide y documenta la política —reintento, cola o tiempo límite— y explica por qué esa decisión no es técnica.