Skip to main content

Form submissions

Validate external form values and submit one typed Convex mutation.

useConvexForm is a submission controller, not a form framework. Your component or form library controls values, touched and dirty state, field arrays, focus, navigation, and messages. useConvexForm validates the values with a Standard Schema and sends them to one Convex mutation.

ts
import { type } from 'arktype'

const checkpointSchema = type({
  balance: 'number > 0',
  note: 'string',
})

const { submit, pending, fieldErrors, formError } = useConvexForm(api.accounts.createCheckpoint, {
  schema: checkpointSchema,
  toArgs: (values) => ({
    balanceCents: Math.round(values.balance * 100),
    note: values.note || undefined,
  }),
  mapError: (error) => ({
    form: error.kind === 'server' ? error.message : 'The checkpoint could not be saved.',
  }),
})

async function save() {
  const result = await submit(
    { balance: balance.value, note: note.value },
    { accountId: accountId.value },
  )
  if (!result.ok) return

  toast.success('Checkpoint saved.')
  navigateTo(`/accounts/${accountId.value}`)
}

The schema is required. It may validate asynchronously or transform values. toArgs receives the parsed output; any mutation arguments it does not produce become the typed second argument to submit().

Render errors

vue
<template>
  <form @submit.prevent="save">
    <input v-model.number="balance" :aria-invalid="Boolean(fieldErrors.balance)" />
    <p v-for="message in fieldErrors.balance" :key="message">
      {{ message }}
    </p>
    <p v-if="formError" role="alert">{{ formError }}</p>
    <button :disabled="pending">Save</button>
  </form>
</template>

Known top-level issue paths become field errors. Nested paths retain their complete path and route to the matching top-level field. Pathless and unknown paths remain visible as form errors.

mapError receives the safe ConvexCallError. It may map a domain error to known submitted fields or a form message. Without mapError, the form message is error.message. For a server error, that is the text your Convex function put in ConvexError data. Backend validation and authorization remain canonical; browser validation is only faster feedback.

Submission rules

  • Expected validation and mutation failures resolve as { ok: false, error }.
  • Success resolves as { ok: true, data }.
  • A second submit() while one is pending rejects with a ConvexCallError with code SUBMIT_IN_PROGRESS and sends no mutation. Disable the submit button while pending is true.
  • reset() returns to idle, clears data and error, and lets a new submission start at once. It cannot cancel or roll back a mutation already accepted by Convex.
  • An identity change, such as signing out, works like reset(): the form returns to idle and clears the previous user's data, error, and field messages at once.
  • A reset, identity change, or component disposal prevents an older completion from repopulating state. The older submission still resolves or rejects its own Promise.
  • A submission that is retired while its schema or toArgs still runs never sends the mutation. It resolves { ok: false, error }. The code is IDENTITY_CHANGED when the signed-in user changed, and CANCELLED after reset() or component disposal.
  • A submission belongs to the user who called submit(). When that user changes while the mutation runs, the submission resolves { ok: false, error } with code IDENTITY_CHANGED, and error.callError.outcome is 'unknown': the mutation may have saved.
  • error.callError.outcome is 'not-sent' when the mutation was never sent. See Error types.

Use useConvexMutation directly for concurrent calls, optimistic writes, uploads, autosave, or commands that are not forms. Use useConvexOperation for several mutations that belong together. Use a dedicated Vue form library when you need field registration, touched/dirty state, field arrays, or wizard behavior; pass its values to submit().