Skip to content
Meeko
Type to search the guides and the API reference. Open it from any page with / or Ctrl K; move with the arrow keys and open a result with Enter.
Get started

Contents

On this page

useApiMutation

Sends an on-demand POST, PUT, PATCH or DELETE request to a server/api endpoint, with pending state, errors and safe retries.

composable Since 1.2.0

useApiMutation(ruta, método, opciones?) prepares a write to a non-GET server/api endpoint. It makes no request until you call mutate({ params, query, body }), and reflects the latest call in pending, data and error. mutate sends the session's CSRF token, checks input against the endpoint contract and returns its response, or rejects with its error.

Write ruta as in useApi, and método is "POST", "PUT", "PATCH" or "DELETE": TypeScript only accepts methods that exist for that route. Application clients generate it and it is auto-imported in .meeko files; your own export named useApiMutation takes priority.

Usage#

meeko
<!-- 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">Save</button>
  <p if:when="create.error.value" role="alert">Could not save.</p>
</template>

Type#

UseApiMutation

ts
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"]
    >;
}

ApiMutation

ts
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>
}

MutationOptions

ts
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
}

RetrySafeMutation

ts
interface RetrySafeMutation<Input, Output> {
	readonly uncertain: ReadonlySignal<UncertainMutation<Input> | null>
	retry(): Promise<Output | undefined>
	discard(): void
}

Parameters#

Parameter Type Default Description
ruta an endpoint path required Such as "/api/notes" or "/api/notes/[id]".
método "POST" | "PUT" | "PATCH" | "DELETE" required One of the endpoint's declared methods for that route. "GET" throws TypeError: useApiMutation: GET <ruta> is a read; use useApi.
opciones.retrySafe { key, storage? } none Makes the write safe to retry: stores the intent with an idempotency key and retries with the same key if no response arrives.
opciones.onSuccess (output, attempt) => unknown none Called once per successful attempt, including automatic retries, with state already updated.
opciones.onError (error, attempt) => unknown none Called once per failed attempt. attempt.uncertain is true if no response arrived.

Return value#

An ApiMutation:

Property Type Description
mutate(input?) Promise<Output> Sends { params, query, body }, checked against the contract. No arguments if the operation requires nothing. A 204 endpoint resolves with undefined. Rejects with a ClientError if the server responds with an error.
pending ReadonlySignal<boolean> true while a call is in flight.
data ReadonlySignal<Output | null> Latest successful call's response, or null.
error ReadonlySignal<Error | null> Latest call's failure, or null.

With retrySafe, adds uncertain (the sent intent without a response), retry() and discard(). Outside a component script, throws Error: useApiMutation("<ruta>", "<método>") must be called while a component script runs, at the top level of <script>.

Examples#

Delete and reload the list#

meeko
<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)">Delete</button>
  </li>
</template>

With useForm#

useForm manages form state and field errors; the mutation handles submission:

meeko
<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">Save</button>
  </form>
</template>

A charge that is not applied twice#

meeko
<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>Sale pending verification: it will retry with the same key, without charging twice.</p>
    <button bind:disabled="sale.pending.value" on:click="() => sale.retry().catch(() => {})">Retry now</button>
  </div>
</template>

If the network fails or the server responds with 5xx, 408 or 429, the intent remains stored and is retried with the same Idempotency-Key header when connection returns, when a component using it mounts or with retry(). The server deduplicates using context.idempotencyKey.

Common mistakes#

Await it with await#

Incorrectts
const create = await useApiMutation("/api/notes", "POST");

meeko check reports MEEK055: useApiMutation no se puede esperar con await; llámalo sin await y espera su mutate(input) en un handler.

Correctts
const create = useApiMutation("/api/notes", "POST");
async function save(text: string): Promise<void> { await create.mutate({ body: { text } }); }

A read operation#

Incorrectts
const read = useApiMutation("/api/users", "GET");

meeko check reports TS2345: Argument of type '"/api/users"' is not assignable to parameter of type '"/api/notes" | "/api/notes/[id]"': only routes with write methods are accepted. To read, use useApi.

Correctts
const users = useApi("/api/users");

The body without body#

mutate receives operation input (params, query, body), not the body directly.

Incorrectts
await create.mutate({ text: "hello" });

meeko check reports TS2353: Object literal may only specify known properties, and 'text' does not exist in type 'ApiMutationInput<…>'.

Correctts
await create.mutate({ body: { text: "hello" } });

Create it in the handler#

Incorrectts
async function save(): Promise<void> {
  await useApiMutation("/api/notes", "POST").mutate({ body: { text: "x" } });
}

The compiler does not flag this (to avoid rejecting custom functions with the same name), but at runtime it throws Error: useApiMutation("/api/notes", "POST") must be called while a component script runs, at the top level of <script>. Create it at the top level:

Correctts
const create = useApiMutation("/api/notes", "POST");
async function save(): Promise<void> { await create.mutate({ body: { text: "x" } }); }

Notes#

  • Never in SSR. mutate only runs in the browser, in a handler. The server never sends mutations, nor do navigation dry runs.
  • Consistent state. Each submission sets pending to true and error to null in one update, and finishes by simultaneously writing data or error and setting pending to false.
  • Security. The request carries the session's CSRF token in x-meeko-csrf and the browser's Origin; the endpoint applies its own guards (auth, authorize, rateLimit). Do not trust the client for authorization.
  • Errors. A server error arrives as a ClientError, with code, status and, for a 400, issues. A failure inside onSuccess or onError goes to the nearest provideErrorHandler with source "event" and does not change the result.
  • retrySafe. Use a key per mutation that includes what distinguishes users or accounts in the same browser. While an uncertain intent exists, mutate rejects with MUTATION_UNCERTAIN without sending anything: resolve it with retry() or discard().

Was this page helpful?

Add a comment

It is sent when you choose “Yes” or “No”. Only the page, your answer and the comment are stored: no cookies and no personal data.

0 of 2000 characters

Edit on GitHub: docs/en/reference/composables/use-api-mutation.md