Composables

useRealtimeLock

Claim exclusive ownership of a resource across clients.

useRealtimeLock

Claim exclusive ownership of a key across all connected clients. Use this to prevent two users from editing the same record at once, dragging the same card, or otherwise stepping on each other.

Usage

<script setup lang="ts">
const { claim, release, ownedByMe, locked } = useRealtimeLock('document:123')
</script>

<template>
  <UButton v-if="!locked" @click="claim()">Start editing</UButton>
  <UButton v-else-if="ownedByMe" color="neutral" @click="release()">Done editing</UButton>
  <UBadge v-else color="warning" variant="subtle">Someone else is editing this</UBadge>
</template>

Type Signature

function useRealtimeLock<TOwnerInfo = string>(
  key: string,
  options?: UseRealtimeLockOptions<TOwnerInfo>
): UseRealtimeLockReturn<TOwnerInfo>

Parameters

key

  • Type: string
  • Required: Yes

A unique identifier for the lock. All clients using the same key contend for the same lock.

options

  • Type: UseRealtimeLockOptions<TOwnerInfo>
  • Required: No
interface UseRealtimeLockOptions<TOwnerInfo> {
  /** Info about this client to show others while it holds the lock (e.g. `{ name, avatarUrl }`) */
  ownerInfo?: TOwnerInfo

  /** Opaque group tag for bulk presence via useRealtimeLockRoom. Not a route/URL concept. */
  room?: string

  /**
   * Auto-release after this many ms of being held, regardless of activity.
   * Omitted = the module-level `lock.defaultTtl` (if any), 0 = never expires.
   */
  ttl?: number

  /** Called whenever the lock becomes free */
  onReleased?: (payload: { changed: boolean }) => void
}

Return Value

interface UseRealtimeLockReturn<TOwnerInfo> {
  claim: () => Promise<boolean>
  release: (options?: { changed?: boolean, meta?: unknown }) => Promise<void>
  forceRelease: () => Promise<boolean>
  ownedByMe: Readonly<Ref<boolean>>
  locked: Readonly<Ref<boolean>>
  ownerInfo: Readonly<Ref<TOwnerInfo | null>>
}

claim()

Attempts to claim the lock. Succeeds if it's currently free or already owned by this client:

const { claim } = useRealtimeLock('document:123', {
  ownerInfo: { name: 'Alice' },
})

const success = await claim()
if (!success) {
  console.log('Someone else already holds this lock')
}

release()

Releases the lock. Resolves once the server has acknowledged; a no-op if this client doesn't own it:

const { claim, release } = useRealtimeLock('document:123')

await claim()
// ... editing happens ...
await release()

Pass changed: true when the release follows an actual edit, so other clients' onReleased can tell that apart from someone abandoning without changing anything. meta is relayed verbatim to other clients, useful for passing a diff or a reason:

await release({ changed: true, meta: { field: 'title' } })

forceRelease()

Forcibly releases the lock regardless of who holds it. Denied by default: it only succeeds if the server has a nuxt-realtime:canForceRelease hook registered that sets ctx.allow = true.

const { forceRelease } = useRealtimeLock('document:123')

async function unlockAsAdmin() {
  const success = await forceRelease()
  if (!success) console.log('Not allowed to force-release')
}

Register the hook on the server:

server/plugins/realtime-force-release.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('nuxt-realtime:canForceRelease', (ctx) => {
    ctx.allow = isAdmin(ctx.connectionId)
  })
})

ownedByMe

Whether this client currently owns the lock:

const { ownedByMe } = useRealtimeLock('document:123')

watchEffect(() => {
  if (ownedByMe.value) console.log('I hold the lock')
})

locked

Whether anyone (not necessarily this client) currently owns the lock:

const { locked, ownedByMe } = useRealtimeLock('document:123')

const canEdit = computed(() => !locked.value || ownedByMe.value)

ownerInfo

Info about the current holder, passed to their claim()'s ownerInfo option, or null if unlocked or unknown:

<script setup lang="ts">
const { locked, ownedByMe, ownerInfo } = useRealtimeLock('document:123', {
  ownerInfo: { name: 'Alice' },
})
</script>

<template>
  <UBadge v-if="locked && !ownedByMe" color="warning" variant="subtle">
    Locked by {{ ownerInfo?.name ?? 'someone' }}
  </UBadge>
</template>

TTL: Auto-Release After a Timeout

By default a lock stays held until it's explicitly released, or the owning connection disconnects. Set ttl to also release it automatically after a fixed duration, regardless of activity, useful as a safety net against a client that crashes without releasing cleanly:

const { claim } = useRealtimeLock('document:123', {
  ttl: 60_000, // auto-release after 1 minute
})

Set a module-wide default in nuxt.config.ts instead of repeating ttl everywhere:

nuxt.config.ts
export default defineNuxtConfig({
  nuxtRealtime: {
    lock: {
      defaultTtl: 60_000,
    },
  },
})

Examples

Exclusive Form Editing

Only let one user edit a record at a time:

RecordEditor.vue
<script setup lang="ts">
const props = defineProps<{ recordId: string }>()
const currentUser = useCurrentUser()

const { claim, release, locked, ownedByMe, ownerInfo } = useRealtimeLock(
  `record:${props.recordId}`,
  { ownerInfo: { name: currentUser.name } },
)

const record = useRealtimeState(`record:${props.recordId}`, { title: '' })

async function startEditing() {
  await claim()
}

async function save() {
  await release({ changed: true })
}
</script>

<template>
  <div class="space-y-2">
    <UBadge v-if="locked && !ownedByMe" color="warning" variant="subtle">
      {{ ownerInfo?.name ?? 'Someone' }} is editing this record
    </UBadge>

    <UInput v-model="record.title" :disabled="locked && !ownedByMe" />

    <UButton v-if="!ownedByMe" :disabled="locked" @click="startEditing">
      Edit
    </UButton>
    <UButton v-else @click="save">Save</UButton>
  </div>
</template>

Kanban Card Drag Lock

Prevent two users from dragging the same card at once:

KanbanCard.vue
<script setup lang="ts">
const props = defineProps<{ cardId: string }>()

const { claim, release, locked, ownedByMe } = useRealtimeLock(`card:${props.cardId}`, {
  room: 'board:main',
})

function onDragStart() {
  claim()
}

function onDragEnd() {
  release({ changed: true })
}
</script>

<template>
  <UCard
    :draggable="!locked || ownedByMe"
    :class="{ 'opacity-50': locked && !ownedByMe }"
    @dragstart="onDragStart"
    @dragend="onDragEnd"
  >
    <slot />
  </UCard>
</template>
See useRealtimeLockRoom for a bulk view of every locked card on the board at once, so you don't need to subscribe to each card's lock individually.

Notify Others When a Lock Is Released

React when a lock someone else held becomes free, distinguishing a real edit from an abandoned session:

const documentData = useRealtimeState('document:123', null)

const { claim } = useRealtimeLock('document:123', {
  onReleased: ({ changed }) => {
    if (changed) {
      documentData.refresh()
    }
  },
})