wandres.dev
ACTIONS Y FORMS · mutaciones progresivas

Estado optimista: pintar el resultado antes de la confirmación

El estado optimista en Solid no se parchea a mano: se deriva. La colección de envíos en vuelo que da `useSubmissions` es una fuente reactiva, y su `input` contiene los datos que aún no confirmó el servidor; pintar esos datos como ítems provisionales junto a los reales de `createAsync` da la respuesta inmediata. Cuando la action termina, la query revalida en una transición y el dato real releva al fantasma sin parpadeo. Cuando falla, el envío deja de estar pending y el fantasma desaparece solo: la reversión no es un camino de código, es la ausencia de la proyección.

⏱ 18 min

La interfaz optimista promete algo tentador: no esperar al servidor. En cuanto el usuario envía, muestras el resultado que esperas —el ítem ya en la lista, la fila ya borrada— y solo corriges si el servidor te desmiente. En muchos frameworks esto es un campo de minas de estado duplicado y reversiones manuales. En Solid es casi anticlimático, porque el estado optimista se deriva de algo que ya tienes: la colección de envíos en vuelo. Sus input son exactamente los datos que aún no confirmó el servidor, y pintarlos es toda la técnica. La reversión, además, no la programas: ocurre por sí sola cuando el envío deja de estar pendiente.

🎯 Al terminar esta lección sabrás
  • Derivar la UI optimista del input de los envíos en vuelo de useSubmissions, sin estado manual.
  • Combinar los datos reales de createAsync con los provisionales de los envíos pendientes.
  • Entender el relevo sin parpadeo: la revalidación corre en una transición que coordina el cambio.
  • Ver por qué la reversión ante un fallo es automática: el fantasma es una proyección del estado pending.

La idea: pintar desde el envío en vuelo

El material del optimismo ya está sobre la mesa: useSubmissions te da la colección de envíos activos, y cada uno guarda en input los datos con que se disparó. Un envío en vuelo es, literalmente, «una nota que el usuario ya mandó pero el servidor aún no confirmó». Pintar su input como un ítem provisional —un fantasma— es mostrar el futuro probable. No necesitas una segunda lista de estado optimista: la lista de envíos pendientes es esa lista.

import { For, Show } from "solid-js";
import { createAsync, useSubmissions } from "@solidjs/router";

function Notas() {
  const notas = createAsync(() => obtenerNotas(), { initialValue: [] });
  const creando = useSubmissions(crearNota);

  return (
    <ul>
      <For each={notas()}>
        {(n) => <li>{n.texto}</li>}
      </For>

      <For each={creando}>
        {(envio) => (
          <Show when={envio.pending}>
            <li class="fantasma">{String(envio.input[0].get("texto"))}</li>
          </Show>
        )}
      </For>
    </ul>
  );
}

Los ítems reales salen de notas(), la fuente de verdad servida por la query; los fantasmas salen de creando, los envíos en vuelo. envio.input[0] es el FormData que se envió, y de él lees el texto que el usuario acaba de escribir. El usuario ve su nota aparecer al instante, aunque el servidor todavía esté trabajando.

flowchart TD
IN[input del envio en vuelo] --> GHOST[se pinta un item fantasma]
GHOST --> DONE[la action termina bien]
GHOST --> FAIL[la action falla]
DONE --> REVAL[revalida la query en una transicion]
REVAL --> REAL[el dato real releva al fantasma]
FAIL --> GONE[el envio deja de estar pending]
GONE --> BACK[el fantasma desaparece y la lista queda intacta]
style REAL fill:#a6e3a1,color:#11111b
style BACK fill:#f9e2af,color:#11111b

El relevo sin parpadeo

La pregunta obvia es qué pasa en el instante de la confirmación: ¿no se verá un parpadeo cuando el fantasma desaparezca y el ítem real aparezca, o peor, un momento con los dos? La respuesta está en cómo Solid coordina el cierre del envío con la revalidación. Al terminar la action, la revalidación de la query corre dentro de una transición: el dato nuevo se prepara antes de aplicarse, y el cambio de la query y la baja del envío se aplican de forma coordinada. El resultado es un relevo limpio —el ítem real ya está listo cuando el fantasma se retira—, sin el destello de duplicado que tendrías si gestionaras ambas listas a mano.

Para borrados, la proyección es la inversa: en vez de añadir un fantasma, ocultas el ítem que un envío pendiente pretende eliminar. Compruebas si algún envío de borrado en vuelo apunta a ese id y, si es así, no lo pintas.

const borrando = useSubmissions(borrarNota);
const estaBorrando = (id: string) =>
  borrando.some((e) => e.pending && e.input[0] === id);

<For each={notas()}>
  {(n) => (
    <Show when={!estaBorrando(n.id)}>
      <li>
        {n.texto}
        <form action={borrarNota.with(n.id)} method="post">
          <button type="submit">Borrar</button>
        </form>
      </li>
    </Show>
  )}
</For>

Como borrarNota.with(n.id) prefija el id, ese id queda en input[0] del envío, y estaBorrando lo compara para esconder la fila en cuanto el usuario pulsa Borrar. El ítem desaparece al instante; cuando el servidor confirma, la query revalida sin él y la ocultación provisional se vuelve permanente.

💡
Distingue lo provisional y anúncialo a la tecnología asistiva

Un ítem optimista y uno confirmado no deberían verse idénticos: dale al fantasma una clase que lo atenúe —opacidad reducida, un pequeño indicador de progreso— para que quede claro que aún no es definitivo. Y como el cambio ocurre sin recargar la página, envuelve la región en un contenedor con aria-live="polite" para que un lector de pantalla anuncie la aparición o la baja del ítem. El optimismo mejora la percepción de velocidad, pero solo es honesto si comunica también su propia incertidumbre.

La reversión es la ausencia del fantasma

Aquí está la joya del modelo. En un enfoque imperativo, «revertir si falla» es un camino de código explícito: guardas el estado anterior, aplicas el cambio optimista, y en el catch restauras lo guardado. Es la parte que más se olvida y peor se prueba. En Solid no existe ese camino porque el fantasma nunca fue un estado que modificaras: era una proyección del envío pendiente. Si la action falla, el envío deja de estar pending —pasa a tener error—, y como el fantasma solo se pintaba mientras pending era cierto, desaparece por sí solo. La lista real, que nunca tocaste, queda exactamente como estaba. La reversión no se ejecuta: simplemente el fantasma deja de existir.

<For each={creando}>
  {(envio) => (
    <>
      <Show when={envio.pending}>
        <li class="fantasma">{String(envio.input[0].get("texto"))}</li>
      </Show>
      <Show when={envio.error}>
        <li class="fallida">
          No se pudo guardar «{String(envio.input[0].get("texto"))}»
          <button onClick={() => envio.retry()}>Reintentar</button>
        </li>
      </Show>
    </>
  )}
</For>

Como el input sobrevive al fallo, puedes incluso ofrecer un retry() que reintente con los mismos datos, o mostrar la entrada fallida en rojo para que el usuario decida. Todo sale del mismo objeto de envío; no hay una copia de seguridad del estado que restaurar.

👻

Fantasma derivado

El item optimista se deriva del input del envio en vuelo; no es una segunda lista de estado que mantengas.

🔄

Relevo en transicion

Al confirmar, la revalidacion corre en una transicion que coordina el cambio y evita el parpadeo del duplicado.

↩️

Reversion gratis

Si el envio falla deja de estar pending y el fantasma desaparece solo; la lista real nunca se toco.

El optimismo no se aplica ni se revierte: se declara como una proyección del estado en vuelo

El error mental que arrastra casi todo el mundo desde el paradigma imperativo es pensar el estado optimista como una mutación temporal de la verdad: tomo la lista real, le añado el ítem que espero, y si el servidor me desmiente, deshago el cambio. Ese modelo genera dos fuentes de verdad que hay que mantener en sincronía —la real y la optimista— y toda la fragilidad vive en las costuras entre ambas: el olvido de revertir, el duplicado que aparece cuando la real llega antes de limpiar la optimista, el estado que se queda pegado tras un error. Solid disuelve el problema cambiando el verbo: no aplicas un cambio optimista, declaras una proyección. Dices «mientras haya envíos en vuelo, píntalos como ítems provisionales» y «mientras un borrado esté pendiente, oculta su fila», y esas frases son funciones puras del estado de los envíos, que el router ya mantiene por ti. La verdad —la query— nunca se modifica optimistamente; solo se le superponen las proyecciones de lo que está en vuelo. Por eso desaparecen los tres dolores clásicos de golpe: no hay duplicado porque el relevo ocurre en una transición coordinada; no hay reversión olvidada porque no hay nada que revertir, el fantasma es la sombra de un envío que, al fallar, deja de proyectarla; y no hay desincronización porque solo existe una fuente de verdad, con las proyecciones dibujadas encima como una capa que el runtime borra y repinta sola. Programar optimismo se reduce entonces a una pregunta de renderizado —cómo dibujo lo que aún no se confirma— y no a una de gestión de estado. Describes la superposición; el sistema reconcilia.

⚔️ Construye una lista optimista que se corrija sola
  1. Combina createAsync(obtenerNotas) con useSubmissions(crearNota) y pinta los envíos en vuelo como ítems fantasma leyendo input[0].
  2. Envía una nota con la red ralentizada y observa que aparece al instante y que, al confirmar, el ítem real la releva sin parpadeo.
  3. Haz que crearNota falle a propósito y verifica que el fantasma desaparece solo, sin que escribas ninguna lógica de reversión.
  4. Implementa el borrado optimista ocultando la fila cuya id esté en un envío de borrarNota pendiente con .with(id).
  5. Ante un fallo, muestra la entrada con su input conservado y un botón retry(); razona por qué el input sigue disponible tras el error.