wandres.dev
ACTIONS Y FORMS · mutaciones progresivas

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.

⏱ 16 min

Hasta ahora leíste el servidor: query y createAsync traían datos hacia la pantalla. Escribir es la otra mitad, y su primitiva es action. Una action envuelve una función asíncrona y le da algo que una función suelta no tiene: identidad. Esa identidad —una url estable— es lo que permite que el router rastree cada envío, empareje su estado con la UI y, cuando no hay JavaScript, sirva la mutación como un POST de verdad. Definir una action es tender un cable con nombre entre un evento del cliente y una escritura en el servidor.

🎯 Al terminar esta lección sabrás
  • Definir una action con la directiva "use server" y comprender el corte cliente/servidor que introduce.
  • Entender que el name fija la url estable con la que el router identifica y sirve la action.
  • Dispararla imperativamente con useAction, que devuelve un llamador que retorna una promesa.
  • Dispararla declarativamente desde un <form action={…} method="post"> con datos del formulario.

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.

ℹ️
La action no elige dónde corre: lo elige la directiva

action es solo el envoltorio que aporta identidad y rastreo. Lo que hace que el cuerpo se ejecute en el servidor es el "use server" de dentro. Puedes tener una action cuyo cuerpo corra en el cliente si omites la directiva —útil para mutar estado local con las mismas garantías de rastreo—, pero el caso canónico, y el de este nivel, es la mutación de servidor con "use server".

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.

📝
Los argumentos cruzan la red: deben ser serializables

Como el stub del cliente empaqueta los argumentos y los manda por HTTP al servidor, lo que le pasas a una action tiene que poder serializarse: primitivas, objetos planos, arrays, FormData, Date. No viajan funciones, ni clases con métodos, ni closures que capturen ámbito. La restricción no es un capricho, es la consecuencia inevitable de que la llamada atraviesa un límite de proceso: al otro lado no existe tu instancia, solo los bytes que quepan en el cuerpo de una petición. Piensa en la firma de una action como en el contrato de una API, no como en la de una función local.

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.

flowchart TD
DEF[action con use server dentro] --> URL[nombre fija una url estable]
URL --> STUB[el cliente guarda un stub rpc]
STUB --> V1[useAction dispara desde codigo]
STUB --> V2[form action dispara desde el markup]
V1 --> SRV[la mutacion corre en el servidor]
V2 --> SRV
style DEF fill:#89b4fa,color:#11111b
style URL fill:#f9e2af,color:#11111b
style SRV fill:#a6e3a1,color:#11111b

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>
🔌

Identidad, no función

action envuelve una funcion asincrona y le da una url estable con la que el router la rastrea y la sirve.

useAction

Devuelve un llamador ligado al router que retorna una promesa; es la via imperativa para disparar en codigo.

📝

form action

Pasa la action a un form con method post y el navegador la invoca con los datos del formulario, sin manejador.

Una action es una sola referencia que cruza la red: RPC con forma de función

El salto conceptual que hay que dar es dejar de ver la action como una función y empezar a verla como una referencia isomórfica que atraviesa el límite de la red sin que tú lo escribas. Cuando defines crearNota, no estás definiendo una función que corre donde la llamas; estás declarando una operación cuya identidad —su .url— es la misma en el cliente y en el servidor, y cuyo cuerpo, por obra del "use server", solo existe de verdad en el servidor. En el cliente esa misma referencia es un stub que empaqueta argumentos, hace un POST a esa url y te devuelve una promesa; en el servidor es el código real que toca la base de datos. Por eso da igual desde dónde la dispares: useAction la llama desde tu código y un <form action> la llama desde el markup, pero ambos hablan con la misma url, y por eso ambos entran en el mismo sistema de rastreo de envíos. El name es la pieza que hace estable esa identidad, y no un detalle de configuración: es lo que garantiza que el formulario que el servidor renderiza y el stub que el cliente hidrata apunten al mismo endpoint. Cuando interiorizas que una action no es «una función que corre en el servidor» sino «un nombre para una mutación que el framework sabe transportar», dejan de sorprenderte todas sus propiedades —que funcione sin JavaScript, que su estado sea observable, que revalide datos al terminar—: todas se siguen de que es, antes que nada, una referencia con identidad de red.

⚔️ Define y dispara tu primera mutación de servidor
  1. Escribe crearNota con "use server" y un name explícito; imprime su propiedad .url y observa que es un identificador estable.
  2. Dispárala con useAction desde un onClick, construyendo un FormData a mano y esperando la promesa con await.
  3. Dispárala ahora desde un <form action={crearNota} method="post"> sin escribir manejador; comprueba que sin method="post" deja de funcionar.
  4. Define borrarNota que reciba id y FormData, y úsala en una fila con borrarNota.with(id); razona por qué el id va antes que el formulario.
  5. Quita el "use server" del cuerpo y describe qué cambia: dónde corre ahora la función y qué garantías pierdes respecto a la versión de servidor.