useApiMutation
Envía bajo demanda una petición POST, PUT, PATCH o DELETE a un endpoint de server/api, con su estado de envío, sus errores y reintentos seguros.
useApiMutation(ruta, método, opciones?) prepara una escritura en un endpoint de server/api que no es GET. No hace ninguna petición hasta que llamas a mutate({ params, query, body }), y refleja la última llamada en pending, data y error. mutate envía el token CSRF de la sesión, comprueba la entrada contra el contrato del endpoint y devuelve su respuesta, o rechaza con su error.
ruta se escribe como en useApi y método es "POST", "PUT", "PATCH" o "DELETE": TypeScript sólo acepta los métodos que existen para esa ruta. Lo generan los clientes de la aplicación y se autoimporta en los .meeko; un export propio llamado useApiMutation tiene prioridad.
Uso#
<!-- app/components/NewNote.meeko -->
<script lang="ts">
const create = useApiMutation("/api/notes", "POST");
const text = signal("");
async function save(): Promise<void> {
await create.mutate({ body: { text: text.value } });
text.value = "";
}
</script>
<template>
<textarea model="text"></textarea>
<button type="button" bind:disabled="create.pending.value" on:click="save">Guardar</button>
<p if:when="create.error.value" role="alert">No se pudo guardar.</p>
</template>Tipo#
type UseApiMutation<Mutations> = {
<Path extends keyof Mutations & string, Method extends keyof Mutations[Path] & string>(
path: Path,
method: Method,
options?: MutationOptions<
ApiMutationInput<MutationOf<Mutations, Path, Method>>,
MutationOf<Mutations, Path, Method>["output"]
> & { readonly retrySafe?: undefined },
): ApiMutation<MutationOf<Mutations, Path, Method>>;
<Path extends keyof Mutations & string, Method extends keyof Mutations[Path] & string>(
path: Path,
method: Method,
options: MutationOptions<
ApiMutationInput<MutationOf<Mutations, Path, Method>>,
MutationOf<Mutations, Path, Method>["output"]
> & { readonly retrySafe: RetrySafeOptions },
): ApiMutation<MutationOf<Mutations, Path, Method>> &
RetrySafeMutation<
ApiMutationInput<MutationOf<Mutations, Path, Method>>,
MutationOf<Mutations, Path, Method>["output"]
>;
}interface ApiMutation<O extends AnyApiOperation> {
mutate(...input: ApiMutationArguments<O>): Promise<O['output']>
readonly pending: ReadonlySignal<boolean>
readonly error: ReadonlySignal<Error | null>
readonly data: ReadonlySignal<O['output'] | null>
}interface MutationOptions<Input = unknown, Output = unknown> {
readonly retrySafe?: RetrySafeOptions
readonly onSuccess?: (output: Output, attempt: MutationAttempt<Input>) => unknown
readonly onError?: (error: Error, attempt: MutationFailure<Input>) => unknown
}interface RetrySafeMutation<Input, Output> {
readonly uncertain: ReadonlySignal<UncertainMutation<Input> | null>
retry(): Promise<Output | undefined>
discard(): void
}Parámetros#
| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
ruta |
ruta de un endpoint | obligatorio | Como "/api/notes" o "/api/notes/[id]". |
método |
"POST" | "PUT" | "PATCH" | "DELETE" |
obligatorio | Uno de los métodos que el endpoint declara para esa ruta. "GET" lanza TypeError: useApiMutation: GET <ruta> is a read; use useApi. |
opciones.retrySafe |
{ key, storage? } |
ninguno | Hace la escritura segura ante reintentos: guarda la intención con una clave de idempotencia y la reintenta con la misma clave si no hay respuesta. |
opciones.onSuccess |
(output, attempt) => unknown |
ninguno | Se llama una vez por cada intento que termina bien, también los reintentos automáticos, con el estado ya actualizado. |
opciones.onError |
(error, attempt) => unknown |
ninguno | Se llama una vez por cada intento que falla. attempt.uncertain es true si no hubo respuesta. |
Valor devuelto#
Una ApiMutation:
| Propiedad | Tipo | Descripción |
|---|---|---|
mutate(input?) |
Promise<Output> |
Envía { params, query, body }, comprobados contra el contrato. Sin argumentos si la operación no exige nada. Un endpoint 204 resuelve con undefined. Rechaza con un ClientError si el servidor responde con error. |
pending |
ReadonlySignal<boolean> |
true mientras una llamada está en curso. |
data |
ReadonlySignal<Output | null> |
Respuesta de la última llamada correcta, o null. |
error |
ReadonlySignal<Error | null> |
Fallo de la última llamada, o null. |
Con retrySafe añade uncertain (la intención enviada sin respuesta), retry() y discard(). Fuera del script de un componente lanza Error: useApiMutation("<ruta>", "<método>") must be called while a component script runs, at the top level of <script>.
Ejemplos#
Borrar y recargar la lista#
<script lang="ts">
const notes = await useApi("/api/notes");
const remove = useApiMutation("/api/notes/[id]", "DELETE");
async function erase(id: string): Promise<void> {
await remove.mutate({ params: { id } });
await notes.refresh();
}
</script>
<template>
<li each:of="notes.data.value" each:as="note" each:key="note.id">
{note.text}
<button type="button" bind:disabled="remove.pending.value" on:click="() => erase(note.id)">Borrar</button>
</li>
</template>Con useForm#
useForm lleva el estado del formulario y los errores por campo; la mutación hace el envío:
<script lang="ts">
const create = useApiMutation("/api/notes", "POST");
const form = useForm({
initial: { text: "" },
submit: (values) => create.mutate({ body: values }),
});
</script>
<template>
<form on:submit="form.submit" novalidate>
<textarea model="form.fields.text"></textarea>
<p if:when="form.errors.value.text">{form.errors.value.text}</p>
<button bind:disabled="form.pending.value">Guardar</button>
</form>
</template>Un cobro que no se aplica dos veces#
<script lang="ts">
const sale = useApiMutation("/api/sales", "POST", {
retrySafe: { key: "pos:sale" },
onSuccess(receipt, attempt) {
if (attempt.trigger !== "mutate") notify(`Venta #${receipt.number} confirmada.`);
},
});
</script>
<template>
<div role="alert" if:when="sale.uncertain.value">
<p>Venta en verificación: se reintentará con la misma clave, sin cobrar dos veces.</p>
<button bind:disabled="sale.pending.value" on:click="() => sale.retry().catch(() => {})">Reintentar ahora</button>
</div>
</template>Si la red falla o el servidor responde 5xx, 408 o 429, la intención queda guardada y se reintenta con la misma cabecera Idempotency-Key al volver la conexión, al montarse un componente que la usa o con retry(). El servidor deduplica con context.idempotencyKey.
Errores comunes#
Esperarlo con await#
const create = await useApiMutation("/api/notes", "POST");meeko check da MEEK055: useApiMutation no se puede esperar con await; llámalo sin await y espera su mutate(input) en un handler.
const create = useApiMutation("/api/notes", "POST");
async function save(text: string): Promise<void> { await create.mutate({ body: { text } }); }Una lectura#
const read = useApiMutation("/api/users", "GET");meeko check da TS2345: Argument of type '"/api/users"' is not assignable to parameter of type '"/api/notes" | "/api/notes/[id]"': sólo admite rutas con métodos de escritura. Para leer, usa useApi.
const users = useApi("/api/users");El cuerpo sin body#
mutate recibe la entrada de la operación (params, query, body), no el cuerpo directamente.
await create.mutate({ text: "hola" });meeko check da TS2353: Object literal may only specify known properties, and 'text' does not exist in type 'ApiMutationInput<…>'.
await create.mutate({ body: { text: "hola" } });Crearlo en el manejador#
async function save(): Promise<void> {
await useApiMutation("/api/notes", "POST").mutate({ body: { text: "x" } });
}El compilador no lo marca (para no rechazar funciones propias con el mismo nombre), pero en ejecución lanza Error: useApiMutation("/api/notes", "POST") must be called while a component script runs, at the top level of <script>. Créalo en el nivel superior:
const create = useApiMutation("/api/notes", "POST");
async function save(): Promise<void> { await create.mutate({ body: { text: "x" } }); }Notas#
- Nunca en SSR.
mutatesólo se ejecuta en el navegador, en un manejador. El servidor nunca envía mutaciones, ni las pasadas en seco de una navegación. - Estado coherente. Cada envío pone
pendingentrueyerrorennullen una sola actualización, y termina escribiendo a la vezdataoerrorypendingenfalse. - Seguridad. La petición lleva el token CSRF de la sesión en
x-meeko-csrfy elOrigindel navegador; el endpoint aplica sus propias guardas (auth,authorize,rateLimit). No confíes en el cliente para autorizar. - Errores. Un error del servidor llega como
ClientError, concode,statusy, en un400,issues. Un fallo dentro deonSuccessuonErrorva alprovideErrorHandlermás cercano con la fuente"event"y no cambia el resultado. retrySafe. Usa una clave por mutación que incluya lo que distinga a usuarios o cuentas del mismo navegador. Mientras haya una intención incierta,mutaterechaza conMUTATION_UNCERTAINsin enviar nada: resuélvela conretry()odiscard().