wandres.dev
ACTIONS Y FORMS · mutaciones progresivas

Redirects, errores y revalidación desde una action

Una action termina de una de tres maneras, y cada una declara el siguiente estado del mundo. `throw redirect(path)` devuelve una respuesta con cabecera Location que el router sigue con una navegación, funcione o no el JavaScript. Los errores esperados se retornan como datos y se leen en `result`; los excepcionales se lanzan y caen en `error` o escalan a un ErrorBoundary. Y toda action revalida queries al terminar: por defecto todas, o acotadas con `json`, `reload` y `revalidate` usando `query.key` y `query.keyFor`.

⏱ 18 min

Mutar el servidor es solo la mitad del trabajo de una action; la otra mitad es decir qué debe pasar después. Y solo hay tres desenlaces posibles: mandar al usuario a otra ruta, avisarle de que algo está mal para que lo corrija, o darle el nuevo dato. Los tres se expresan desde el cuerpo de la action —throw redirect, un valor de error retornado, un valor de datos retornado— y los tres pueden ir acompañados de una revalidación que le dice al cliente qué lecturas han quedado obsoletas. Dominar la salida de una action es entender que no solo cambia el servidor: declara cómo se ve el mundo una vez cambiado.

🎯 Al terminar esta lección sabrás
  • Redirigir con throw redirect(path, init?) y entender por qué funciona con y sin JavaScript.
  • Separar errores esperados (retornados, leídos en result) de excepcionales (lanzados, en error o al ErrorBoundary).
  • Saber que toda action revalida queries al terminar: por defecto todas.
  • Acotar la revalidación con json, reload y revalidate usando query.key y query.keyFor.

Redirigir: throw redirect

redirect(url, init?) no navega por sí mismo: devuelve una respuesta —un Response con la cabecera Location apuntando al destino—. El router la reconoce y navega. Lo idiomático es lanzarla con throw, porque una redirección suele ser una salida temprana desde el fondo de la lógica —«si no hay sesión, fuera de aquí»— y throw corta en seco el resto de la función. También puedes retornarla; el router trata igual una respuesta lanzada que una devuelta.

import { action, redirect } from "@solidjs/router";

const iniciarSesion = action(async (form: FormData) => {
  "use server";
  const email = String(form.get("email") ?? "");
  const clave = String(form.get("clave") ?? "");

  const usuario = await autenticar(email, clave);
  if (!usuario) return { error: "Credenciales inválidas" }; // se queda y corrige
  await crearSesion(usuario);

  throw redirect("/panel");                                  // corta y navega
}, "iniciarSesion");

La virtud de que redirect sea una respuesta HTTP de verdad es que respeta el progressive enhancement sin esfuerzo. Con JavaScript, el router lee el Location y hace una navegación de cliente. Sin JavaScript, el envío nativo del formulario recibe un 302 y el navegador redirige por su cuenta —el mismo mecanismo que la web usa desde siempre—. Una sola línea cubre los dos mundos. Y admite ajustes: status (por defecto 302), headers, y un revalidate que estudiaremos enseguida.

throw redirect("/panel", { status: 303, revalidate: obtenerUsuario.key });

El status merece un apunte fino. Tras un envío POST, redirigir con un 303 See Other es lo más correcto: instruye al navegador a seguir el destino con un GET, el patrón clásico POST-redirect-GET que evita que recargar la página reenvíe el formulario. El 302 por defecto funciona en la práctica para las navegaciones de cliente, pero cuando cuidas el comportamiento sin JavaScript, el 303 es el código que expresa con precisión «terminé de procesar tu envío, ahora ve a ver el resultado».

Errores: retornar lo esperado, lanzar lo excepcional

La lección de useSubmission estableció la dualidad; aquí la explotamos como estrategia de errores. Un error de validación —email mal formado, credenciales incorrectas, nombre duplicado— es un desenlace esperado del formulario: no es un fallo del sistema, es información para el usuario. Lo modelas retornando un valor, que aterriza en submission.result, y lo lees en la UI sin sobresalto.

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

function FormLogin() {
  const envio = useSubmission(iniciarSesion);
  return (
    <form action={iniciarSesion} method="post">
      <input name="email" type="email" required />
      <input name="clave" type="password" required />

      <Show when={envio.result?.error}>
        {(msg) => <p role="alert">{msg()}</p>}
      </Show>

      <button type="submit" disabled={envio.pending}>Entrar</button>
    </form>
  );
}

Para errores por campo, retorna una forma más rica —return { errores: { email: "…", clave: "…" } }— y pinta cada mensaje junto a su input. Reserva el throw para lo excepcional: la base de datos caída, un servicio externo que no responde, un invariante roto. Eso no es información para el usuario, es un fallo; lanzarlo lo deja en submission.error y, si no lo manejas ahí, escala al ErrorBoundary más cercano, que es justo donde deben tratarse los fallos genuinos.

⚠️
No lances lo que el usuario puede corregir

Modelar un error de validación como una excepción lanzada es un antipatrón: rompe el flujo del formulario, puede tumbar un subárbol si escala a un ErrorBoundary, y trata como catástrofe algo tan común como un email mal escrito. La regla es nítida: si el usuario puede arreglarlo reescribiendo el formulario, es un valor que retornas; si no puede hacer nada al respecto, es una excepción que lanzas.

Revalidación: por defecto todo, luego acotas

El detalle que cierra el círculo con las queries: toda action revalida al terminar. Por defecto, cuando la action retorna un valor normal, el router marca todas las queries como obsoletas y las vuelve a pedir. Es el comportamiento seguro —nunca te quedas con datos rancios— porque una mutación cualquiera podría haber afectado a cualquier lectura. Por eso el ejemplo optimista de la lección anterior funcionaba sin que pidieras nada: la creación de una nota revalidaba la lista sola.

Cuando esa red de seguridad es demasiado ancha —revalidar veinte queries cuando solo una cambió—, acotas. json(datos, { revalidate }) retorna datos y limita la revalidación a las claves que indiques; reload({ revalidate }) hace lo mismo cuando no necesitas retornar datos. Las claves salen de la query: query.key apunta a todos los argumentos cacheados de esa query, y query.keyFor(arg) a un juego concreto de argumentos.

import { action, json, reload } from "@solidjs/router";

// Retorna el dato y revalida solo la lista de notas
const crearNota = action(async (form: FormData) => {
  "use server";
  const nota = await db.notas.crear({ texto: String(form.get("texto") ?? "") });
  return json(nota, { revalidate: obtenerNotas.key });
}, "crearNota");

// No retorna datos: solo revalida las tareas de un proyecto concreto
const marcarHecha = action(async (proyecto: string, id: string) => {
  "use server";
  await db.tareas.completar(id);
  return reload({ revalidate: obtenerTareas.keyFor(proyecto) });
}, "marcarHecha");

Esas mismas claves sirven fuera de una action: la función revalidate(clave) fuerza la recarga de una query desde cualquier evento —un botón de refrescar, un mensaje de websocket que avisa de un cambio en el servidor— sin necesidad de mutar nada. Es el mismo mecanismo de invalidación que una action dispara al terminar, ahora en tus manos de forma imperativa: revalidate(obtenerNotas.key) marca la query como obsoleta y quien la esté leyendo con createAsync la vuelve a pedir dentro de una transición. Entre las tres formas —json y reload desde dentro de la mutación, revalidate desde fuera— cubres todos los momentos en que necesitas que los datos vuelvan a estar frescos.

flowchart TD
A[la action termina] --> R[throw redirect]
A --> E[return con errores de validacion]
A --> D[return con datos]
R --> NAV[el router navega al destino con location]
E --> UI[la UI lee result y muestra los errores]
D --> REV[revalida queries al terminar]
REV --> DEF[por defecto todas las queries]
REV --> NAR[acotadas con json o reload y query key]
style NAV fill:#89b4fa,color:#11111b
style UI fill:#f9e2af,color:#11111b
style REV fill:#a6e3a1,color:#11111b
➡️

throw redirect

Devuelve un Response con Location; el router navega con JS y el navegador redirige sin JS con un 302.

⚠️

return vs throw

Retorna los errores que el usuario puede corregir y lanza los fallos genuinos que escalan a un ErrorBoundary.

♻️

revalidate acotado

Por defecto se revalida todo; con json, reload y query key limitas el radio a las lecturas que cambiaron.

La salida de una action no cierra una operación: declara el próximo estado consistente del mundo

Conviene resistir la lectura estrecha de que una action «hace una cosa en el servidor y ya». Su verdadera responsabilidad es más ambiciosa: dejar el sistema en un estado consistente y declarar cuál es. Los tres desenlaces son tres respuestas a la pregunta «¿cómo se ve el mundo ahora?». Un redirect responde «el usuario ya no pertenece a esta ruta, pertenece a aquella» —tras un login, tras crear un recurso que tiene su propia página—, y por eso es una respuesta HTTP y no una llamada a un navigate: el nuevo lugar del usuario es parte del resultado de la mutación, no un efecto que dispares aparte. Un error retornado responde «el mundo no cambió, y esto es lo que falta para que cambie», dejando al usuario en su sitio con la información para corregir. Y un dato retornado responde «el mundo cambió, y esto es lo nuevo», pero la pieza que lo hace coherente no es el dato en sí, sino la revalidación que lo acompaña: la action no se limita a mutar el servidor, le dice al cliente qué de lo que estaba mostrando ha quedado obsoleto. Ahí está la inversión respecto al modelo imperativo. En una SPA clásica, después de un POST exitoso tú recuerdas manualmente refrescar las listas afectadas, y cada olvido es un dato rancio en pantalla. En Solid el olvido es imposible por defecto: revalidar todo es la postura inicial, y acotar con query.key es una optimización que te ganas cuando sabes exactamente qué tocaste. La consecuencia es que la consistencia entre lo que el servidor sabe y lo que el usuario ve deja de ser tu responsabilidad artesanal y pasa a ser una garantía del framework. Escribir la salida de una action, entonces, no es cerrar una función: es firmar la nueva versión coherente del mundo y confiar en que el router la propague hasta el último nodo que dependía de ella.

⚔️ Diseña las tres salidas de una action
  1. Escribe iniciarSesion que retorne { error } con credenciales inválidas y throw redirect("/panel") al autenticar; comprueba que el redirect funciona con el JavaScript desactivado.
  2. Muestra el error de validación leyendo envio.result?.error y razona por qué no lo lanzaste como excepción.
  3. Provoca un fallo genuino lanzando una excepción y verifica que aterriza en envio.error o escala a un ErrorBoundary.
  4. Haz que crearNota retorne json(nota, { revalidate: obtenerNotas.key }) y confirma que solo se revalida esa query, no todas.
  5. Usa reload({ revalidate: obtenerTareas.keyFor(proyecto) }) en una action con argumento prefijado y explica la diferencia entre .key y .keyFor.