wandres.dev
PATRONES DE DATA FETCHING · cache, dedup, prefetch

Mutaciones optimistas con action y useSubmission

Actualizar la interfaz antes de que el servidor confirme, y revertir si falla. Cómo `action` modela una mutación con nombre, cómo `useSubmission` y `useSubmissions` exponen los envíos en vuelo con su entrada, su estado pendiente y su error, cómo pintar el estado optimista a partir de esos envíos, y por qué el rollback ante un fallo es automático: al dejar de estar pendiente, la interfaz vuelve a leer la verdad del servidor sin código de deshacer.

⏱ 17 min

Una interfaz optimista no espera al servidor: pinta el resultado que asume correcto en cuanto el usuario actúa, y corrige solo si la realidad la contradice. En SolidStart el vehículo es action —una mutación con nombre— junto a useSubmission y useSubmissions, que exponen los envíos en vuelo con su entrada, su estado pendiente y su posible error. Con esas piezas, pintar el estado optimista es leer los envíos pendientes; y revertir ante un fallo no exige código de deshacer, porque cuando un envío deja de estar pendiente la interfaz vuelve por sí sola a la verdad del servidor. La UI optimista se convierte así en una función de los envíos, no en un estado que tú sincronizas a mano.

🎯 Al terminar esta lección sabrás
  • Modelar una mutación con action y dispararla con useAction o un formulario.
  • Leer los envíos en vuelo con useSubmission y useSubmissions: entrada, pendiente y error.
  • Pintar el estado optimista a partir de la entrada de los envíos pendientes.
  • Entender por qué el rollback ante un fallo es automático, sin código de deshacer.

action: una mutación con nombre

Igual que query da identidad a una lectura, action da identidad a una escritura. Envuelves la función que muta el servidor y le das un nombre; a cambio, el framework la vincula al ciclo de envíos que luego observarás.

// data/todos.ts
import { action, query } from "@solidjs/router";

export const getTodos = query(async () => {
  return (await (await fetch("/api/todos")).json()) as Todo[];
}, "todos");

export const addTodo = action(async (texto: string) => {
  const res = await fetch("/api/todos", {
    method: "POST",
    body: JSON.stringify({ texto }),
  });
  if (!res.ok) throw new Error("no se pudo crear");
  return (await res.json()) as Todo;
}, "addTodo");

La disparas de dos maneras. Con un formulario, action={addTodo} da mejora progresiva —funciona incluso sin JavaScript—. O de forma imperativa con useAction, que te devuelve una función normal a la que pasas argumentos tipados. Usaremos la segunda porque deja la entrada del envío en una forma cómoda de pintar.

import { useAction } from "@solidjs/router";
const agregar = useAction(addTodo);
// agregar("Comprar pan") dispara la mutacion

Leer los envíos en vuelo

El corazón del patrón es que cada disparo de una acción genera un envío observable. useSubmission te da el último; useSubmissions, la colección de todos los que están en vuelo o acaban de completarse. Cada envío expone input —los argumentos con que se llamó—, pending, result y error.

import { useSubmissions } from "@solidjs/router";

const enviando = useSubmissions(addTodo);
// enviando.pending -> hay alguno en vuelo
// [...enviando] -> lista de envios, cada uno con input, pending, result, error

Un envío pendiente de addTodo guarda en input[0] el texto que le pasaste. Esa es la materia prima del optimismo: ya sabes lo que el usuario quiere crear antes de que el servidor lo confirme, así que puedes pintarlo ya.

Elige el hook según la cardinalidad. useSubmission —singular— sigue el último envío y basta cuando la interacción es de uno en uno: un botón de guardar, un formulario que se bloquea mientras viaja. useSubmissions —plural— reúne todos los envíos vivos y es el que necesitas cuando el usuario puede encolar varios sin esperar, como añadir tres tareas seguidas. Su pending agregado, además, es la señal natural para deshabilitar controles mientras algo está en curso.

const envio = useSubmission(addTodo);
<button disabled={envio.pending}>Guardar</button> // se bloquea mientras viaja

Pintar el estado optimista

La lista real vive en getTodos a través de createAsync. Junto a ella, mapeamos los envíos pendientes de addTodo como filas “fantasma”, pintadas con la entrada del propio envío. El usuario ve su tarea aparecer al instante, atenuada mientras viaja.

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

function ListaTodos() {
  const todos = createAsync(() => getTodos());
  const agregar = useAction(addTodo);
  const enviando = useSubmissions(addTodo);
  const [texto, setTexto] = createSignal("");

  return (
    <>
      <button onClick={() => agregar(texto())}>Anadir</button>
      <ul>
        <For each={todos()}>{(t) => <li>{t.texto}</li>}</For>
        <For each={[...enviando].filter((s) => s.pending)}>
          {(s) => <li class="fantasma">{s.input[0]}</li>}
        </For>
      </ul>
    </>
  );
}

Cuando la acción resuelve y la lista se revalida (lo verás en la siguiente lección), la fila real llega desde el servidor y el envío deja de estar pendiente: la fila fantasma desaparece justo cuando la verdadera ocupa su sitio. El relevo es invisible.

sequenceDiagram
participant U as Usuario
participant UI as Interfaz
participant S as Servidor
U->>UI: agregar Comprar pan
UI->>UI: pinta fila fantasma desde input
UI->>S: envio en vuelo pending
alt exito
  S-->>UI: creado ok
  UI->>UI: revalida lista y quita fantasma
else fallo
  S-->>UI: error
  UI->>UI: envio deja de estar pending, fantasma se va
end

Revertir si falla: el rollback es automático

Aquí está la elegancia del modelo. No escribes lógica de deshacer. Si la acción lanza un error, ese envío deja de estar pendiente —pasa a tener error— y, como tu fila fantasma solo se pinta para envíos con pending verdadero, se desvanece sola. La interfaz revierte a la única fuente de verdad que le queda: la lista del servidor, que nunca llegó a incluir la tarea fallida. El estado optimista era una capa derivada de los envíos vivos; al morir el envío, la capa se retira sin dejar rastro.

import { useSubmission } from "@solidjs/router";

const envio = useSubmission(addTodo);
// Muestra el fallo y ofrece reintentar sin tocar la lista real
<Show when={envio.error}>
  <p role="alert">No se pudo guardar. Reintenta.</p>
</Show>

Puedes además llamar a envio.clear() para descartar un envío completado o fallido y limpiar su rastro del historial de submissions. Pero lo esencial es el principio: la verdad vive en el servidor; el optimismo es una proyección temporal de los envíos sobre esa verdad, y desaparece en cuanto el envío se resuelve, con éxito o con fracaso.

⚠️
No confundas el estado optimista con el estado real

El error clásico es escribir el resultado optimista dentro de la caché real —mutar getTodos a mano para meter la fila— y quedarte sin forma limpia de revertir si el servidor rechaza. Mantén las dos capas separadas: la lista real solo cambia cuando el servidor confirma y la query se revalida; lo optimista se pinta aparte, derivado de los envíos pendientes. Así el rollback es no hacer nada —el envío muere y su proyección con él— en lugar de un segundo mutación que deshaga la primera y que, si también falla, te deja en un estado imposible.

Lo optimista no es un estado que guardas: es una proyección de lo que está en vuelo

El salto mental que hace robusta a una interfaz optimista es dejar de verla como “escribo el cambio ya y luego lo confirmo o lo deshago” —dos escrituras, una de las cuales puede fallar también— y empezar a verla como una proyección pura de los envíos en vuelo sobre la verdad del servidor. En el modelo ingenuo hay dos fuentes de verdad peleándose: la caché que mutaste a mano y la respuesta que aún no llegó, y reconciliarlas exige lógica de deshacer que es, ella misma, otra oportunidad de fallar. En el modelo de Solid solo hay una fuente de verdad —los datos del servidor en la query— y una capa derivada que la sobreescribe visualmente mientras hay algo pendiente. Esa capa no se guarda: se calcula en cada render a partir de useSubmissions, exactamente igual que un createMemo se calcula a partir de sus signals. Y como toda derivación, no necesita mantenimiento: cuando su entrada cambia —el envío deja de estar pendiente— la salida se actualiza sola. Por eso el rollback no existe como código; existe como ausencia de código. No reviertes nada porque nunca escribiste nada que revertir: solo dejaste de proyectar un envío que ya no está vivo. Interioriza esto y toda una clase de bugs desaparece —dobles inserciones, estados a medio deshacer, listas que quedan con la fila fantasma pegada tras un error— porque esos bugs solo son posibles cuando tratas lo optimista como estado propio. Trátalo como lo que es, una función de lo que está en vuelo, y la corrección deja de ser algo que programas para ser algo que se deriva.

⚔️ Pinta el futuro y deja que el servidor lo confirme
  1. Define addTodo con action y dispárala con useAction; comprueba que useSubmissions(addTodo) refleja un envío con pending verdadero mientras viaja.
  2. Pinta las filas fantasma leyendo input[0] de los envíos pendientes, junto a la lista real de getTodos.
  3. Fuerza un fallo en el servidor y verifica que la fila fantasma desaparece sola, sin que escribas ni una línea de deshacer.
  4. Muestra envio.error con un Show y ofrece reintentar; confirma que la lista real nunca llegó a contener la tarea fallida.
  5. Explica por qué mutar getTodos a mano para el optimismo complicaría el rollback frente a proyectar los envíos aparte.