Composables

useRealtimeRoom

Scope state, events, presence, and locks together under one room.

useRealtimeRoom

An explicit room concept that scopes useRealtimeState, useRealtimeEvents, useRealtimePresence, and useRealtimeLock together under one id, with server-side lifecycle hooks and a single auth checkpoint instead of gating every handler individually.

It's a convenience layer, not a requirement. useRealtimeLock(key, { room }) and useRealtimePresence(room) keep working standalone without ever calling useRealtimeRoom; they pass through the same server-side membership path implicitly.

Usage

<script setup lang="ts">
const room = useRealtimeRoom('document:123')

const doc = room.state('content', '')
const { members } = room.presence({ info: { name: 'Alice' } })
const { claim, release, ownedByMe } = room.lock('editing')
</script>

<template>
  <div class="space-y-2">
    <p>{{ Object.keys(members).length }} people here</p>
    <UTextarea v-model="doc" :disabled="!ownedByMe" class="w-full" />
    <UButton v-if="!ownedByMe" @click="claim()">Edit</UButton>
    <UButton v-else @click="release({ changed: true })">Save</UButton>
  </div>
</template>

Type Signature

function useRealtimeRoom(roomId: string): UseRealtimeRoomReturn

Parameters

roomId

  • Type: string
  • Required: Yes

A unique identifier for the room. Calling useRealtimeRoom joins it immediately.

Return Value

interface UseRealtimeRoomReturn {
  joined: Readonly<Ref<boolean>>
  join: () => Promise<boolean>
  leave: () => Promise<void>
  presence: <TInfo = unknown>(options?: UseRealtimePresenceOptions<TInfo>) => UseRealtimePresenceReturn<TInfo>
  lock: <TOwnerInfo = string>(key: string, options?: Omit<UseRealtimeLockOptions<TOwnerInfo>, 'room'>) => UseRealtimeLockReturn<TOwnerInfo>
  locks: () => UseRealtimeLockRoomReturn
  state: <T>(key: string, defaultValue?: T, options?: useRealtimeStateOptions) => UseRealtimeStateReturn<T>
  events: <TEventMap = Record<string, unknown>>(options?: UseRealtimeEventsOptions) => UseRealtimeEventsReturn<TEventMap>
}

joined

Whether this client is currently a member of the room, reflecting the last join/leave acknowledgment:

const room = useRealtimeRoom('document:123')

watchEffect(() => {
  if (!room.joined.value) console.log('Not in the room')
})

join() / leave()

useRealtimeRoom joins automatically on setup and leaves on unmount. Call these directly for manual control:

const room = useRealtimeRoom('document:123')

async function switchDocument() {
  await room.leave()
  // ... navigate elsewhere ...
}

join() resolves to whether it succeeded. It's gated server-side by the nuxt-realtime:canJoinRoom hook (allowed by default).

presence()

Presence scoped to this room, equivalent to useRealtimePresence(roomId, options):

const room = useRealtimeRoom('document:123')
const { members } = room.presence({ info: { name: 'Alice' } })

lock()

A lock scoped to this room, equivalent to useRealtimeLock(key, { ...options, room: roomId }):

const room = useRealtimeRoom('document:123')
const { claim, release, ownedByMe } = room.lock('editing')

locks()

Read-only, bulk view of every lock claimed via room.lock() in this room, equivalent to useRealtimeLockRoom(roomId). Use this for an overview across many keys; use lock(key) to claim or release one specific key:

const room = useRealtimeRoom('board:main')
const { locks } = room.locks()

state()

State scoped to this room. A thin client-side convenience that namespaces key under the room id and delegates to useRealtimeState; it adds no new server protocol:

const room = useRealtimeRoom('document:123')
const title = room.state('title', 'Untitled')

events()

Event pub/sub scoped to this room. Namespaces the channel under the room id and delegates to useRealtimeEvents:

const room = useRealtimeRoom('document:123')
const { subscribe, publish } = room.events()

subscribe('comment-added', (comment) => {
  console.log('New comment:', comment)
})

publish('comment-added', { text: 'Nice work!' })

Server Hooks

useRealtimeRoom fires three server-side hooks around joining and leaving. Register them in a Nitro plugin.

Gating who can join

nuxt-realtime:canJoinRoom is the single checkpoint for per-room auth, run once per connection the first time it joins a given room. Allowed by default; set ctx.allow = false to deny:

server/plugins/realtime-room-auth.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('nuxt-realtime:canJoinRoom', async (ctx) => {
    ctx.allow = await userCanAccessRoom(ctx.connectionId, ctx.roomId)
  })
})

Room created / emptied

nuxt-realtime:roomCreated fires once when a room's membership goes from 0 to 1, the natural place for provisioning logic:

server/plugins/realtime-room-lifecycle.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('nuxt-realtime:roomCreated', async ({ roomId }) => {
    await db.rooms.upsert({ id: roomId, createdAt: new Date() })
  })

  nitroApp.hooks.hook('nuxt-realtime:roomEmpty', async ({ roomId }) => {
    await db.rooms.archive(roomId)
  })
})

nuxt-realtime:roomEmpty fires once when membership goes from 1 to 0, either an explicit leave that empties the room or (after every member's disconnect grace period lapses without a reconnect) the connection registry's sweep.

Examples

Collaborative Document Page

Combine presence, a title lock, and shared state on a single document page:

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

const room = useRealtimeRoom(`document:${props.documentId}`)

const { members } = room.presence({
  info: { name: currentUser.name, avatarUrl: currentUser.avatarUrl },
})

const title = room.state('title', 'Untitled')
const { claim, release, ownedByMe, ownerInfo } = room.lock('title-edit')

const { subscribe, publish } = room.events()
const comments = ref([])

subscribe('comment-added', (comment) => {
  comments.value.push(comment)
})

function addComment(text: string) {
  publish('comment-added', { id: crypto.randomUUID(), text, author: currentUser.name })
}
</script>

<template>
  <div class="space-y-4">
    <UAvatarGroup>
      <UAvatar
        v-for="(info, connectionId) in members"
        :key="connectionId"
        :src="info.avatarUrl"
        :alt="info.name"
        :text="info.name[0]"
      />
    </UAvatarGroup>

    <div class="flex items-center gap-2">
      <UInput v-model="title" :disabled="!ownedByMe" />
      <UButton v-if="!ownedByMe" :disabled="!!ownerInfo" @click="claim()">
        Edit title
      </UButton>
      <UButton v-else @click="release({ changed: true })">Done</UButton>
    </div>

    <ul class="space-y-1">
      <li v-for="comment in comments" :key="comment.id">
        <strong>{{ comment.author }}:</strong> {{ comment.text }}
      </li>
    </ul>
  </div>
</template>