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.
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.
- Modelar una mutación con
actiony dispararla conuseActiono un formulario. - Leer los envíos en vuelo con
useSubmissionyuseSubmissions: 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.
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.
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.
- Define
addTodoconactiony dispárala conuseAction; comprueba queuseSubmissions(addTodo)refleja un envío conpendingverdadero mientras viaja. - Pinta las filas fantasma leyendo
input[0]de los envíos pendientes, junto a la lista real degetTodos. - Fuerza un fallo en el servidor y verifica que la fila fantasma desaparece sola, sin que escribas ni una línea de deshacer.
- Muestra
envio.errorcon unShowy ofrece reintentar; confirma que la lista real nunca llegó a contener la tarea fallida. - Explica por qué mutar
getTodosa mano para el optimismo complicaría el rollback frente a proyectar los envíos aparte.