wandres.dev
ACTIONS Y FORMS · mutaciones progresivas

useSubmission y useSubmissions: el estado de la mutación

Un envío de action no es fuego y olvido: el router lo materializa como un objeto reactivo con `pending`, `result`, `error`, `input`, `clear` y `retry`. `useSubmission` devuelve el último envío para pintar el estado de un formulario; `useSubmissions` devuelve todos los activos más un `pending` agregado, para operaciones concurrentes. La dualidad de salida gobierna dónde cae el resultado: lo que la action retorna aterriza en `result`, lo que lanza aterriza en `error`. Un filtro opcional acota qué envíos observa cada hook.

⏱ 16 min

Disparar una mutación y desentenderse es fácil; lo difícil es contarle al usuario qué está pasando. ¿Se está guardando? ¿Falló? ¿Puedo reintentar? En Solid no tienes que cablear señales a mano para responder a eso, porque el router materializa cada envío como un objeto reactivo. Ese objeto tiene el estado completo de la mutación —si está en vuelo, qué devolvió, qué error lanzó, con qué datos se disparó— y tu UI se limita a leerlo. useSubmission te da el último envío; useSubmissions, todos. La mutación deja de ser un evento que se pierde y pasa a ser un valor que se observa.

🎯 Al terminar esta lección sabrás
  • Leer los campos de un envío: pending, result, error, input, y accionar clear y retry.
  • Distinguir useSubmission (el último envío) de useSubmissions (todos, con pending agregado).
  • Comprender la dualidad de salida: lo retornado cae en result, lo lanzado cae en error.
  • Acotar qué envíos observa un hook con su segundo parámetro, un filtro sobre el input.

El envío como objeto reactivo

useSubmission(accion) devuelve un objeto que refleja el último envío de esa action y se actualiza de forma reactiva a lo largo de su ciclo de vida. Sus campos son el vocabulario con el que describes cualquier estado de formulario: pending es true mientras la mutación está en vuelo; result guarda el valor que la action retornó; error guarda la excepción que lanzó; input conserva los argumentos con que se disparó —para un formulario, un array cuyo primer elemento son los datos enviados—. Además trae dos acciones: clear() descarta el envío, y retry() lo relanza con el mismo input.

import { Show } from "solid-js";
import { useSubmission } from "@solidjs/router";

function FormNota() {
  const envio = useSubmission(crearNota);
  return (
    <form action={crearNota} method="post">
      <input name="texto" />
      <button type="submit" disabled={envio.pending}>
        {envio.pending ? "Guardando…" : "Guardar"}
      </button>

      <Show when={envio.error}>
        {(err) => (
          <p role="alert">
            {err().message}
            <button type="button" onClick={() => envio.retry()}>Reintentar</button>
            <button type="button" onClick={() => envio.clear()}>Descartar</button>
          </p>
        )}
      </Show>
    </form>
  );
}

Fíjate en que no hay ningún createSignal, ningún try/catch en el componente, ningún estado manual de carga. Todo el estado del formulario —botón deshabilitado mientras guarda, mensaje de error, botones de reintento— es una lectura directa del objeto que useSubmission te entrega ya cableado al router.

Un matiz de ciclo de vida que ahorra sorpresas: el objeto de envío persiste tras completarse —con result o error ya poblados— hasta que otro envío lo reemplaza o llamas a clear(). Eso te permite seguir mostrando el desenlace después de que pending haya vuelto a false: un «Guardado» que se desvanece, un error que permanece a la vista. Y retry() no reconstruye nada: reejecuta la action con el input conservado, lo que lo vuelve ideal para un botón de reintento tras un fallo de red pasajero.

flowchart LR
ENV[envio disparado] --> PEND[pending true con input guardado]
PEND --> OK[la action retorna un valor]
PEND --> KO[la action lanza una excepcion]
OK --> RES[result con el valor y pending false]
KO --> ERR[error con la excepcion y pending false]
RES --> CL[clear descarta el envio]
ERR --> RT[retry relanza el mismo input]
style RES fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111b

Retornar cae en result, lanzar cae en error

La dualidad más importante de este modelo es dónde aterriza cada salida de la action, porque define cómo la UI la trata. Lo que la action retorna —un valor, un objeto de errores de validación— aterriza en envio.result: es un desenlace esperado, parte del flujo normal. Lo que la action lanza —una excepción— aterriza en envio.error: es un desenlace excepcional. Esta separación no es un detalle de implementación; es una palanca de diseño que usas a conciencia.

const crearNota = action(async (form: FormData) => {
  "use server";
  const texto = String(form.get("texto") ?? "").trim();
  if (!texto) return { error: "El texto no puede estar vacío" }; // -> envio.result
  try {
    return await db.notas.crear({ texto });                      // -> envio.result
  } catch {
    throw new Error("No se pudo guardar, intenta de nuevo");     // -> envio.error
  }
}, "crearNota");

La regla que se deriva: retorna los desenlaces que la UI debe pintar como parte del formulario —errores de validación campo a campo, un aviso de duplicado— y lanza los que son fallos genuinos —la base de datos caída, un permiso denegado—. Los primeros los lees en result sin sobresalto; los segundos los captura error, y si no los manejas ahí, escalan al ErrorBoundary más cercano. Verás esta distinción llevada al detalle en la lección de errores y redirects.

useSubmissions: varios envíos a la vez

useSubmission colapsa todo a un único envío, el más reciente, lo cual es perfecto para un formulario donde solo importa la operación en curso. Pero cuando disparas la misma action muchas veces sin esperar —borrar cinco filas de una lista, añadir varios ítems seguidos—, cada disparo es un envío distinto y todos coexisten en vuelo. Para eso está useSubmissions, que devuelve una colección iterable con todos los envíos activos, más un campo pending agregado que es true si cualquiera sigue pendiente.

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

function BarraDeEstado() {
  const envios = useSubmissions(crearNota);
  const enVuelo = () => envios.filter((e) => e.pending).length;
  return (
    <Show when={envios.pending}>
      <p aria-live="polite">Guardando {enVuelo()} nota(s)…</p>
    </Show>
  );
}

Cada elemento de la colección es un objeto de envío completo, con su propio pending, result, error e input. Esa colección es la materia prima del estado optimista de la próxima lección: recorrer los envíos en vuelo y pintar sus input como resultados provisionales.

Conviene precisar qué guarda input, porque la próxima lección lo exprime: es el array de argumentos con que se llamó a la action, en orden. Para un envío de formulario, input[0] son los datos enviados —el FormData o el URLSearchParams—. Si prefijaste con .with(id), ese id ocupa input[0] y los datos del formulario pasan a input[1]. Leer input es, entonces, reconstruir con exactitud la intención del usuario en el instante del envío, antes de que el servidor respondiera —justo lo que hace falta para dibujar un resultado que aún no está confirmado—.

💡
El segundo parámetro filtra qué envíos observa el hook

Tanto useSubmission como useSubmissions aceptan un filtro como segundo argumento: una función que recibe el input del envío y devuelve un booleano. Solo se observan los envíos que pasan el filtro. Es la forma de tener varios formularios de la misma action en una página sin que el estado de uno se filtre a otro, o de reaccionar solo a envíos con cierto dato.

const envio = useSubmission(crearNota, ([form]) => form.get("id") === nota.id);

pending

True mientras la mutacion esta en vuelo; lo lees para deshabilitar el boton o mostrar un indicador.

📬

result y error

Lo retornado cae en result, lo lanzado cae en error; la dualidad decide como la UI trata cada desenlace.

🧾

input, clear, retry

input conserva los datos enviados; retry relanza con ese mismo input y clear descarta el envio.

La mutación deja de ser un evento y se vuelve un valor que la UI observa

El cambio de mentalidad que hay que consumar es dejar de pensar la mutación como un evento —algo que ocurre, dispara efectos secundarios y se desvanece— para pensarla como un valor reactivo que persiste y se puede leer. En el modelo imperativo clásico, enviar un formulario es llamar a una función; para saber si sigue en curso mantienes un booleano a mano, para saber si falló envuelves la llamada en un try/catch y guardas el error en otra variable, para poder reintentar recuerdas los argumentos en una tercera. Tres piezas de estado manual, fáciles de desincronizar. Solid invierte esto: el router ya mantiene ese estado por ti y te lo entrega como un objeto cuyos campos son reactivos, de modo que tu componente no gestiona el ciclo de vida del envío, solo lo describe. Escribes «el botón está deshabilitado cuando pending», «el error se muestra cuando hay error», «reintentar llama a retry», y el sistema mantiene esas afirmaciones ciertas en todo momento. La dualidad result/error es la otra mitad de la elegancia: al decidir entre retornar y lanzar, tú colocas cada desenlace en el carril correcto —el esperado que la UI pinta con naturalidad, el excepcional que puede escalar a un límite de error— sin ramas condicionales que lo distribuyan. Y useSubmissions completa el cuadro para la concurrencia: no un envío, sino la colección viva de todos ellos, cada uno con su propio estado, lista para ser proyectada en pantalla. Cuando ves el envío como un valor y no como un suceso, el formulario deja de ser una máquina de estados que programas y pasa a ser una vista de un estado que ya existe.

⚔️ Pinta el ciclo de vida completo de un envío
  1. Añade a un formulario un botón que se deshabilite con envio.pending y cambie su texto a «Guardando…» mientras dura.
  2. Haz que la action retorne { error } en un caso y lance una excepción en otro; comprueba que el primero aparece en result y el segundo en error.
  3. Muestra envio.error con un Show y añade botones que llamen a retry() y clear(); verifica que retry reusa el input anterior.
  4. Dispara la misma action tres veces seguidas y usa useSubmissions para mostrar cuántos envíos siguen en vuelo con su pending agregado.
  5. Coloca dos formularios de la misma action en una página y usa el filtro del segundo parámetro para que cada uno solo reaccione a su propio envío.