Inicio / Artículos

Interfaz de usuario optimista sin desincronizaciones: capturas, reversiones y solicitudes canceladas

Aprenda a crear actualizaciones optimistas que permanezcan correctas: capture el estado, actualice al instante, realice un retroceso en caso de fallo, cancele las solicitudes obsoletas y sepa cuándo no utilizar este patrón.

Editor: REACTAPP.TOP10 min de lectura2290 palabras

Fuentes: Frontend Design Patterns: Optimistic Updates

En una conexión rápida, el viaje de ida y vuelta detrás de un botón “me gusta” es imperceptible. En un tren que se desplaza entre una señal LTE débil y un 3G apenas utilizable, ese mismo viaje de ida y vuelta puede tardar cinco o diez segundos, y durante ese tiempo el corazón simbólico permanece vacío mientras el usuario se pregunta si la pulsación realmente se registró. Las actualizaciones optimistas reducen esa brecha al cambiar primero la interfaz y luego solicitar información al servidor. Esta guía muestra cómo implementar este patrón en tres pasos claros, cómo protegerlo contra solicitudes simultáneas y cómo determinar dónde encaja y dónde representa una desventaja.

El problema oculto en un simple toque

Imagínese una publicación con 999 me gusta y un corazón dibujado alrededor. En la implementación más estricta, al tocar el corazón se envía una solicitud a un servidor que puede estar en otro continente. El servidor autentica al usuario, escribe en una base de datos y responde; solo entonces el corazón se vuelve rojo y el contador asciende a 1,000. Esa espera es aceptable en unos pocos cientos de milisegundos, pero resulta molesta si dura cinco segundos. Las personas que no ven respuesta lo intentan de nuevo, luego una tercera vez, y finalmente abandonan la aplicación; por eso este retraso es una forma muy efectiva de perder usuarios.

Tocar el corazón también genera más trabajo que simplemente cambiar un valor booleano. Una plataforma típica realiza tres acciones en el backend:

  • Llama a un punto de extremo de API que incrementa el contador de me gusta de esa publicación.
  • Añade la publicación a una colección guardada en la cuenta del usuario para que pueda ser encontrada posteriormente.
  • Envía una señal al sistema de recomendaciones, la parte del backend que decide qué mostrar a continuación, para que el contenido similar aparezca con más frecuencia.
  • Todos los tres dependen de la red. Sin embargo, en el frontend, el toque solo necesita cambiar dos valores de estado: si el corazón está lleno y el número que hay junto a él. Si la interfaz espera a que se produzca cada efecto del lado del servidor antes de modificar cualquiera de esos valores, la aplicación parece defectuosa incluso cuando funciona correctamente.

    El optimismo como una mentira deliberada y recuperable

    Una actualización optimista resuelve esto al hacer que el frontend modifique su propio estado de inmediato, partiendo de la suposición de que la solicitud que acaba de enviar tendrá éxito. En el instante en que el dedo deja la pantalla, el corazón está rojo y la cuenta muestra 1,000, aunque aún no haya nada llegado al servidor. Esa pequeña mentira es lo que hace que la interacción parezca instantánea.

    Solo es aceptable si existe un plan para cuando la suposición falla. El tren podría entrar en un túnel y perder la conexión, o el servidor podría devolver un error. Dejar la pantalla en 1,000 mientras la base de datos sigue indicando 999 genera una desincronización: la interfaz y la fuente de verdad no coinciden. Entonces, la parte frontal debe volver al estado real, es decir, un corazón con contorno y el número 999, y por lo general informar al usuario mediante un mensaje emergente, un texto breve que aparece brevemente y desaparece por sí mismo, como “No se pudo guardar. Inténtelo de nuevo.”

    Implementación del patrón en tres pasos

    Cambiar una variable y esperar que la solicitud funcione es una suposición, no un diseño. Una actualización optimista fiable separa tres responsabilidades: capturar el estado actual, mostrar el resultado esperado y luego reconciliarlo con la respuesta del servidor.

    Paso 1: capturar una instantánea

    Antes de que algo en la pantalla cambie, registre los valores que el usuario puede ver actualmente, como si el post ha sido marcado como favorito y su conteo actual. El manejador recibe el post, un setter de estado y una función para mostrar mensajes emergentes, e importa la llamada a la API que eventualmente realizará:

    // likeButton.js
    import { updateLikeOnServer } from "./api.js";
    
    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
      // act two goes here
      // act three goes here
    }
    

    Después de este paso, tendrá un único objeto intacto que describe con exactitud lo que hay en la pantalla, listo para ser restaurado si ocurre algún problema. Es importante copiar los dos campos a un nuevo objeto: si mantiene una referencia a un objeto mutable que cambia posteriormente, la “instantánea” también cambiaría junto con él.

    Paso 2: aplicar el resultado esperado

    A continuación, actualice la interfaz de usuario directamente, incluso antes de que comience la llamada a la red:

    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
    
      setPost({ liked: true, likeCount: post.likeCount + 1 });
      // act three goes here
    }
    

    La pantalla ahora muestra lo que solicitó el usuario: un corazón rojo y un conteo más alto, mientras que el servidor aún no sabe nada. Un detalle a tener en cuenta en su propio código: este ejemplo pasa un objeto completamente nuevo con solo liked y likeCount. Si su método de asignación reemplaza todo el registro en lugar de combinar los campos, distribuya los datos del registro existente en el nuevo valor para que propiedades como id no se pierdan.

    Paso 3: reconciliarse con el servidor

    Finalmente, envíe la solicitud real y mantenga el estado optimista o elimínelo. La solicitud se encuentra dentro de un bloque try; si surge un error, el manejador restaura la instantánea y muestra el mensaje:

    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
      setPost({ liked: true, likeCount: post.likeCount + 1 });
      try {
        await updateLikeOnServer(post.id);
      } catch (error) {
        setPost(previousState);
        showToast("Couldn't save that. Try again.");
      }
    }
    

    El ayudante de la API utiliza fetch del navegador. Tenga en cuenta que fetch solo rechaza las solicitudes en caso de fallos de red, no por códigos de error HTTP; por eso el ayudante verifica response.ok y lanza una excepción explícitamente. Sin esa verificación, una respuesta 500 se consideraría un éxito y el proceso de reversión nunca se ejecutaría:

    // api.js
    export async function updateLikeOnServer(postId) {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: "POST",
      });
    
      if (!response.ok) {
        throw new Error("Like request failed");
      }
      return response.json();
    }
    

    Después del tercer paso solo hay dos resultados posibles: o bien la solicitud tuvo éxito y el estado optimista se hizo verdadero, o bien falló y la interfaz volvió al estado inicial del primer paso. En ambos casos, la interfaz nunca muestra algo que el backend no pueda soportar durante más tiempo del necesario para que la solicitud falle.

    Dónde valen la pena las actualizaciones optimistas

    La estructura adicional solo merece la pena cuando la reversión es sencilla y la velocidad percibida es muy importante. Tres categorías se ajustan consistentemente a este perfil:

    1. Interacciones sociales. Los mecanismos de “me gusta”, seguir y reaccionar implican un riesgo bajo. Si uno de los muchos intentos diarios falla, el costo es que el usuario tenga que intentarlo nuevamente.
    2. Mensajería. En aplicaciones de chat como WhatsApp o Slack, un mensaje enviado con una conexión deficiente aparece inmediatamente en la conversación, a menudo desvanecido o con un pequeño ícono de reloj gris. Se trata de una actualización optimista que muestra una marca visible de pendiente: el mensaje aparece en la pantalla antes incluso de llegar al servidor.
    3. Panales de arrastrar y soltar. Arrastrar una tarjeta a la columna “Hecho” de un panel kanban como Jira o Trello sería como hacerlo a través de arena húmeda si la tarjeta tuviera que esperar confirmación. En el mejor de los casos, llega al instante; si la solicitud falla, vuelve a su columna original.

    Un refinamiento útil del caso de los mensajes es el indicador de pendiente. Mostrar que una acción aún no está confirmada permite ser rápido y honesto al mismo tiempo, lo cual atenúa la sorpresa en caso de que se produzca un retroceso.

    Dónde el optimismo se vuelve peligroso

    Imagínese ahora una aplicación bancaria. Un inquilino pulsa “Enviar” para transferir cinco mil dólares de alquiler a un propietario. Con una actualización optimista, la pantalla indica inmediatamente que la transferencia tuvo éxito y que el saldo ahora es cero, por lo que el usuario cierra la aplicación creyendo que el alquiler ya fue pagado.

    Detrás de escena la solicitud falla; no importa si es por fondos insuficientes o por una transferencia rechazada. El usuario sigue creyendo que el dinero se ha transferido hasta que el propietario llama preguntando por el alquiler. Como mínimo, eso significa una solicitud de soporte y una confianza dañada, y ocurre porque se aplicó un patrón diseñado para acciones económicas y reversibles a una situación en la que cometer un error tiene consecuencias reales.

    Esa misma precaución se aplica a algunas categorías relacionadas:

    1. Transacciones financieras. Mostrar un indicador de carga. La espera indica que el sistema verificó realmente la operación antes de confirmarla.
    2. Borrados irreversibles. Cuando alguien elimina su cuenta, la pantalla no debe vaciarse hasta que el servidor confirme que los datos han desaparecido.
  • Cambios de seguridad. Las actualizaciones de contraseñas y la configuración de dos factores requieren certeza, no una interfaz que afirme de antemano el éxito.
  • Lo importante no es cuán significativa parezca una acción, sino cuán costoso resulta equivocarse al respecto. Hacer que alguien espere tres segundos más por un indicador de carga es mucho mejor que decirle que el dinero se transfirió cuando en realidad no lo hizo.

    Cuando dos toques compiten entre sí

    Existe un segundo modo de fallo que solo aparece cuando el usuario vuelve a actuar antes de que finalice la primera solicitud. En el tren lento, al usuario le gusta la publicación, pasándola de 999 a 1,000, y de inmediato deja de gustarle, volviendo a 999. Supongamos que dar like tarda cinco segundos en completarse mientras que dejar de gustarle solo necesita dos; entonces, dejar de gustarle puede llegar primero.

    El servidor luego procesa la acción de “no me gusta” para una publicación que aún no considera como “me gusta”, por lo que no ocurre nada; unos segundos después procesa la acción de “me gusta”. La cuenta termina marcada como “me gusta” en el servidor, mientras que la pantalla muestra lo opuesto a lo que el usuario hizo por última vez. Cuando el estado final se determina según el orden de llegada en lugar del orden de las acciones del usuario, se produce una condición de carrera.

    Cancelación de solicitudes obsoletas con AbortController

    La solución habitual es la cancelación: cuando se inicia una solicitud más reciente para el mismo recurso, se le indica al navegador que abandone la anterior. El AbortController incorporado puede cancelar una solicitud fetch que ya está en curso. El ejemplo mantiene una referencia al controlador activo, lo cancela antes de iniciar una nueva solicitud y elige POST o DELETE según el estado deseado:

    // likeButton.js
    let activeRequest = null;
    
    async function toggleLike(postId, shouldLike) {
      if (activeRequest) {
        activeRequest.abort();
      }
      const controller = new AbortController();
      activeRequest = controller;
      await fetch(`/api/posts/${postId}/like`, {
        method: shouldLike ? "POST" : "DELETE",
        signal: controller.signal,
      });
    }
    

    Cada nueva interacción cancela la solicitud anterior, por lo que solo se debe ejecutar la acción más reciente. Hay dos consideraciones importantes que deben tenerse en cuenta antes de confiar únicamente en esto:

    • Al abortar, el navegador deja de esperar la respuesta; esto no garantiza que el servidor nunca haya recibido ni procesado la solicitud. Para obtener garantías más sólidas, diseñe el endpoint de acuerdo con el estado final deseado (me gustó o no me gustó) para que las solicitudes repetidas o reordenadas converjan, o añada un número de secuencia que el servidor pueda utilizar para ignorar escrituras obsoletas.
    • Una solicitud fetch abortada genera un error de tipo AbortError. Si combina esto con la operación de deshacer del paso tres, verifique si existe dicho error y omita la operación de deshacer; de lo contrario, cancelar una solicitud obsoleta anularía incorrectamente el estado optimista más reciente.

    En las aplicaciones React, las bibliotecas de datos se encargan en gran medida de estas tareas internas. TanStack Query, que gestiona el caché del estado del servidor y los ciclos de vida de las solicitudes, y Redux Toolkit, el conjunto de herramientas oficial para escribir lógica Redux, ofrecen soporte integrado para actualizaciones optimistas y cancelación, por lo que rara vez es necesario escribir manualmente el código del AbortController. Consulte su documentación para saber exactamente qué se cancela en cada caso; sigue siendo útil comprender el mecanismo subyacente. Para conocer más a fondo cómo fallan las operaciones de reversión con el propio hook de React, consulte cinco modos de fallo en las reversiones optimistas.

    Puntos clave

    • Un viaje de ida y vuelta por la red puede ser lo suficientemente lento como para hacer que una aplicación funcional parezca defectuosa; por eso las actualizaciones optimistas muestran primero el resultado esperado y lo confirman posteriormente.
  • Estructura cada actualización optimista como captura, aplicación y reconciliación; la captura es lo que hace que el retroceso sea sencillo.
  • Haz que los fallos sean visibles: fetch no rechaza los errores HTTP, así que lanza una excepción cuando !response.ok, e informa al usuario con un mensaje emergente al realizar el retroceso.
  • Utiliza este patrón para acciones económicas y reversibles como me gusta, mensajes y movimientos en el tablero; muestra un indicador de carga para pagos, eliminaciones y cambios de seguridad.
  • Protege contra situaciones competitivas cancelando las solicitudes sobrescritas, ignorando AbortError en la lógica de retroceso y prefiriendo endpoints que establezcan un estado final en lugar de alternarlo.
  • La velocidad es una característica que la red no te dará gratis. Una interfaz optimista acepta que no puede controlar la conexión, responde al usuario con confianza de todos modos y permanece lista para retractar esa respuesta en el momento en que se demuestre errónea.

    Lecturas relacionadas

  • Diseñando pantallas de React para estados de carga, vacío, error y reintentos — Aprenda a modelar cada estado en el que pueden encontrarse las pantallas y formularios de React, desde estructuras básicas y actualizaciones hasta resultados vacíos, errores útiles y protección contra envíos duplicados.
  • Las claves de React como identidad de componente: preservación y restablecimiento de estado — Aprenda por qué las claves de React determinan qué instancia de componente sobrevive a un renderizado, cómo las claves de índice causan fugas de estado entre filas y cómo utilizar una clave para restablecer un formulario.