Skip to main content

Multi-step operations

Run several Convex calls and uploads for the user who started them, and stop when that user changes.

Some work needs several requests in a row: read a draft, upload a file, then publish. Each request must run as the user who started the work. If that user signs out, or another user signs in, the remaining requests must not run.

useConvexOperation gives you this. Pass it a function that sends each request through the operation it receives. run() starts a new operation and calls your function with it and with the arguments of run().

app/components/PublishDraft.vue
<script setup lang="ts">
import type { Id } from '~~/convex/_generated/dataModel'
import { api } from '#convex/api'

const props = defineProps<{ draftId: Id<'drafts'> }>()

const {
  run: publish,
  pending: publishing,
  error,
} = useConvexOperation(async (op, cover: File) => {
  const draft = await op.query(api.drafts.get, { draftId: props.draftId })
  const uploadUrl = await op.mutation(api.files.generateUploadUrl)
  const coverId = await op.upload(uploadUrl, cover)
  return op.mutation(api.posts.publish, { draftId: draft._id, coverId })
})

async function onCoverSelected(event: Event) {
  const cover = (event.target as HTMLInputElement).files?.[0]
  if (!cover) return
  // `error` shows the failure; a change of the signed-in user clears it.
  await publish(cover).catch(() => {})
}
</script>

<template>
  <input type="file" :disabled="publishing" @change="onCoverSelected" />
  <p v-if="error">{{ error.message }}</p>
</template>

In plain Vue, import useConvexOperation from @lupinum/better-convex-vue.

useConvexOperation returns run, data, status, pending, error, and reset, like useConvexAction. Only the newest run changes the state. data is the result of your function.

What an operation does

run() records the signed-in user at that moment. A query, mutation, or action step first waits until authentication is ready. Then every step, including upload, checks that the user is still the same and sends its request.

  • The user changed before a step was sent: the step rejects with IDENTITY_CHANGED and outcome: 'not-sent'. Nothing was sent.
  • The user changed while a step was in flight: the step rejects at once with IDENTITY_CHANGED and outcome: 'unknown'. The server may have run it.

After an identity change, every later step of the operation rejects with IDENTITY_CHANGED and outcome: 'not-sent'. Unless your function catches it, run() rejects with that error. The state returns to 'idle': data and error of the previous user are cleared, and the IDENTITY_CHANGED error never shows in error. A new run() starts an operation for the new user.

Stop an operation

The operation stops when:

  • the signed-in user changes;
  • you call op.cancel();
  • you call reset(), which also returns the state to 'idle';
  • the component or effect scope that called useConvexOperation() ends.

After cancel(), reset(), or the end of the scope, later steps reject with CANCELLED and outcome: 'not-sent'. A running upload() is aborted and rejects with CANCELLED and outcome: 'unknown'. A query, mutation, or action that was already sent finishes with its real result.

Read op.retired to check whether the operation stopped. Pass op.signal to your own work, such as fetch, so that it stops too:

ts
const { run } = useConvexOperation(async (op, draftId: Id<'drafts'>) => {
  const draft = await op.query(api.drafts.get, { draftId })
  const response = await fetch(renderUrl(draft), { signal: op.signal })
  return op.mutation(api.posts.publish, { draftId, html: await response.text() })
})

op.signal aborts with the ConvexCallError that stopped the operation as its reason. It aborts only while your function runs. Once your function has settled, the operation no longer watches the signed-in user, so the signal no longer aborts.

Upload inside an operation

op.upload(url, file, { onProgress, maxSize, allowedTypes }) sends file to a Convex storage upload URL and resolves with its storage ID. With maxSize or allowedTypes, a file that fails them rejects with FILE_TOO_LARGE or FILE_TYPE_NOT_ALLOWED and outcome: 'not-sent', and nothing is sent. For a single file with progress state and a completion step, use useConvexFileUpload. Its complete step receives an operation, so it runs in the same way.

Retry a failed step

outcome tells you whether a request was sent. It does not make a retry safe:

  • outcome: 'not-sent': the request never reached Convex. A retry sends it for the first time.
  • outcome: 'unknown': the request may have run. A retry can run it twice.

Make a write safe to repeat before you retry it automatically. See Idempotent writes.

When to use it

Use useConvexOperation when one user action sends two or more requests that depend on each other. For one request, use useConvexMutation or useConvexAction. They stop in the same way when the user changes.

Keep the rules of the work in Convex. The operation only makes sure that the requests belong to one user. Each Convex function still checks that the user may do the work.