action(): definir y disparar una mutación de servidor
Una action envuelve una función asíncrona y le da identidad de endpoint: por dentro lleva un `use server` que la parte en dos mitades —un stub RPC en el cliente y el cuerpo real en el servidor—. La misma referencia se dispara de dos maneras: imperativamente con `useAction`, que devuelve un llamador que retorna una promesa, o declarativamente pasándola a un `<form action={…} method="post">`, donde el navegador la invoca con los datos del formulario. El nombre no es cosmético: fija la url estable con la que el router empareja envíos y sirve el POST.
Qué es una action y qué la parte en dos
Una action es una función asíncrona envuelta por action, con un "use server" en su cuerpo que marca la frontera de ejecución. Ese cierre es el corazón del modelo: el compilador de SolidStart corta la función en dos mitades. En el servidor queda el cuerpo real, con acceso a la base de datos, los secretos y el sistema de archivos. En el cliente queda un stub RPC —un sustituto diminuto que serializa sus argumentos, hace un POST a una url y devuelve una promesa con la respuesta—. Tú escribes una sola función; el framework la desdobla.
import { action } from "@solidjs/router";const crearNota = action(async (form: FormData) => { "use server"; const texto = String(form.get("texto") ?? ""); if (!texto.trim()) throw new Error("La nota está vacía"); await db.notas.crear({ texto });}, "crearNota");El segundo argumento, "crearNota", es el name, y no es decorativo: fija la propiedad .url con la que el router identifica la action de forma estable entre cliente y servidor. Sin nombre, el router deriva una url del módulo compilado; con nombre, tú controlas ese identificador —imprescindible para que el emparejamiento de envíos y el <form> apunten siempre al mismo sitio aunque el bundle cambie de hash—.
Ese corte tiene además una consecuencia de seguridad que conviene nombrar: el cuerpo de servidor nunca llega al bundle del cliente. Las credenciales de la base de datos, las claves de API, la lógica de autorización que escribas dentro del "use server" viven solo en el servidor; el cliente recibe la url y el código de serialización, nada más. Por eso una action es el lugar natural para lo sensible: la frontera que el compilador dibuja es también una frontera de confianza, y cruzarla exige una petición HTTP explícita que puedes vigilar.
Dispararla con useAction
La vía imperativa es useAction: le pasas la action y te devuelve un llamador ligado al router actual. Ese llamador acepta los argumentos de la action y retorna una promesa con su resultado, de modo que lo invocas desde un onClick, un efecto o cualquier manejador, y puedes esperar su resolución con await.
import { useAction } from "@solidjs/router";function Editor() { const crear = useAction(crearNota); async function guardar() { const form = new FormData(); form.set("texto", "Mi primera nota"); await crear(form); // dispara la mutacion y espera la respuesta } return <button onClick={guardar}>Guardar</button>;}useAction no es un mero atajo de crearNota(...): es lo que engancha el envío al router para que su estado sea observable —lo verás con useSubmission— y para que la revalidación de queries se dispare al terminar. Llamar a la action pelada por fuera del router pierde ese cableado. La regla es simple: para invocar en código, pasa siempre por useAction.
El valor que resuelve esa promesa es exactamente lo que la action retorna, de modo que puedes encadenar lógica de cliente tras la confirmación del servidor —cerrar un diálogo, limpiar el campo, mostrar un aviso—. Y useAction compone con .with(): si prefijas argumentos, el llamador que obtienes ya los lleva puestos y solo le entregas el resto. Entre la vía imperativa y la declarativa no hay jerarquía; eliges por el disparador: un <form> cuando la mutación nace de un envío, useAction cuando nace de cualquier otro evento de tu código.
Dispararla desde un <form>
La vía declarativa es entregar la action al atributo action de un <form> con method="post". El navegador —no tú— la invoca al enviar, pasándole los datos del formulario como argumento. No escribes ningún manejador de envío: la conexión entre el formulario y la mutación es el propio atributo.
function NuevaNota() { return ( <form action={crearNota} method="post"> <input name="texto" placeholder="Escribe una nota" /> <button type="submit">Crear</button> </form> );}El method="post" es obligatorio: sin él, el navegador trataría el envío como una navegación GET y la action no se dispararía. Y como la action tiene una .url estable, ese <form> es un formulario HTML legítimo apuntando a un endpoint real —la semilla del progressive enhancement que estudiarás a continuación—.
No escribes un onSubmit, no llamas a preventDefault, no construyes el FormData a mano: el navegador hace todo eso y le entrega a la action los datos del formulario como argumento. Ese argumento será un FormData o un URLSearchParams según el enctype, un matiz que la próxima lección desmenuza. Lo esencial ahora es el cambio de rol: en la vía imperativa tú disparas la action; en la declarativa la dispara el navegador al enviar, y tu única declaración es el atributo action.
Prefijar argumentos con .with()
Una action recibe un único flujo de argumentos, pero a menudo necesitas fijar uno antes de que el formulario aporte el resto —el id de la fila que se borra, el usuario dueño del recurso—. Para eso está .with(), que devuelve otra action con los argumentos iniciales ya rellenados; los datos del formulario se añaden después de ellos.
const borrarNota = action(async (id: string, _form: FormData) => { "use server"; await db.notas.borrar(id);}, "borrarNota");// En una fila: el id va prefijado, el FormData lo pone el navegador al enviar<form action={borrarNota.with(nota.id)} method="post"> <button type="submit">Borrar</button></form>