Skip to main content

Concurrent operations

Represent overlapping mutations and actions without misleading shared loading state.

One mutation or action composable exposes state for its latest call lifecycle. Decide whether operations should share that state before reusing one instance.

One form, one operation state

ts
import { normalizeConvexError, type ConvexCallError } from '@lupinum/better-convex-nuxt/errors'

const { mutate: saveSettings, pending: saving } = useConvexMutation(api.settings.update)
const formError = ref<ConvexCallError | null>(null)

async function submit() {
  if (saving.value) return
  try {
    await saveSettings(form.value)
  } catch (error) {
    formError.value = normalizeConvexError(error)
  }
}

This fits a form where only one submission should be active.

Independent rows need independent state

For a list with per-row actions, create an explicit per-row component:

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

const props = defineProps<{ projectId: Id<'projects'> }>()
const { mutate: archiveProject, pending: archiving } = useConvexMutation(api.projects.archive)
</script>

<template>
  <button :disabled="archiving" @click="archiveProject({ projectId: props.projectId })">
    {{ archiving ? 'Archiving…' : 'Archive' }}
  </button>
</template>

Each component instance has its own operation state. One row does not disable every row.

Latest-call state

When calls overlap through one composable, only the latest call updates data, error, and status. Earlier calls still resolve or reject for the code that called them. After reset(), no call that is still running updates the state, and a call that was not sent yet is never sent: it rejects with CANCELLED.

Do not use one pending ref as an exact count of all network operations. Use separate instances or your own queue when the product must show every job.

Disable duplicate writes deliberately

Disable or deduplicate when repeating the operation would be harmful. Backend idempotency remains the reliable protection against retries, double clicks, and network ambiguity.

Identity transition

When the signed-in user changes, the composable clears its call state and ignores results of calls that are still running. Those calls reject with IDENTITY_CHANGED. Read error.outcome:

  • 'unknown': the call was sent. The server may already have saved the write.
  • 'not-sent': the call was never sent. Nothing was saved.

For several calls that belong together, use useConvexOperation. It stops the remaining calls when the user changes.