Composables

useRealtimeEvents

Publish and subscribe to events across clients.

useRealtimeEvents

Publish and subscribe to events across clients in real-time. Perfect for notifications, chat messages, live updates, or any ephemeral communication that doesn't need to be persisted.

Usage

<script setup>
const { subscribe, publish } = useRealtimeEvents()

// Subscribe to events
subscribe('notifications', (data) => {
  console.log('Received:', data)
})

// Publish an event
function notify() {
  publish('notifications', { message: 'Hello!' })
}
</script>

Type Signature

function useRealtimeEvents<TEventMap = Record<string, unknown>>(
  options?: UseRealtimeEventsOptions
): UseRealtimeEventsReturn<TEventMap>

Options

interface UseRealtimeEventsOptions {
  /**
   * Timeout for publish acknowledgments in milliseconds
   * @default 5000
   */
  publishTimeout?: number

  /**
   * Middleware functions applied to every incoming event before subscribers are called.
   * Each middleware receives the mutable event object and a `next` function.
   * Mutate `event.data` to transform the payload; omit calling `next()` to block the event.
   */
  middleware?: EventMiddleware[]
}

type EventMiddleware = (
  event: { channel: string; data: unknown },
  next: () => void
) => void

Return Value

interface UseRealtimeEventsReturn<TEventMap = Record<string, unknown>> {
  subscribe: <K extends string>(
    channel: K,
    callback: (data: K extends keyof TEventMap ? TEventMap[K] : unknown, actualChannel?: string) => void,
    options?: UseRealtimeEventsSubscribeOptions
  ) => () => void

  publish: <K extends string & keyof TEventMap>(
    channel: K,
    data: TEventMap[K],
    options?: UseRealtimeEventsSubscribeOptions
  ) => Promise<void>

  unsubscribe: (channel: string) => void
}

Methods

subscribe()

Subscribe to events on a channel. Supports exact channel names, namespace wildcards (chat:*), and the global wildcard (*).

When using a wildcard pattern, the callback receives the actual matched channel name as a second argument.

subscribe<K>(
  channel: K,
  callback: (data: unknown, actualChannel?: string) => void,
  options?: UseRealtimeEventsSubscribeOptions
): () => void

Parameters:

ParameterTypeDescription
channelstringChannel name, namespace:*, or *
callback(data, actualChannel?) => voidFunction called when an event is received
optionsUseRealtimeEventsSubscribeOptionsOptional settings

Returns: An unsubscribe function

const { subscribe } = useRealtimeEvents()

// Basic subscription
subscribe('chat', (message) => {
  console.log('New message:', message)
})

// With TypeScript
interface ChatMessage {
  user: string
  text: string
  timestamp: number
}

subscribe<ChatMessage>('chat', (message) => {
  console.log(`${message.user}: ${message.text}`)
})

// Store unsubscribe function
const unsubscribe = subscribe('channel', callback)

// Later, unsubscribe
unsubscribe()

publish()

Publish an event to a channel.

publish<T>(
  channel: string,
  data: T,
  options?: UseRealtimeEventsSubscribeOptions
): Promise<void>

Parameters:

ParameterTypeDescription
channelstringThe channel name to publish to
dataTThe event data to send
optionsUseRealtimeEventsSubscribeOptionsOptional settings

Returns: A Promise that resolves when the server acknowledges the event

const { publish } = useRealtimeEvents()

// Basic publish
await publish('notifications', { message: 'Hello!' })

// With error handling
try {
  await publish('chat', {
    user: 'Alice',
    text: 'Hello everyone!',
    timestamp: Date.now(),
  })
} catch (error) {
  console.error('Failed to send message:', error)
}

unsubscribe()

Unsubscribe from all callbacks on a channel.

unsubscribe(channel: string): void
const { subscribe, unsubscribe } = useRealtimeEvents()

subscribe('channel', callback1)
subscribe('channel', callback2)

// Remove all subscriptions for this channel
unsubscribe('channel')

Wildcard Subscriptions

Subscribe to multiple channels at once using wildcard patterns.

Namespace wildcard (namespace:*)

Matches any channel that starts with the given prefix:

const { subscribe, publish } = useRealtimeEvents()

// Receives chat:message, chat:typing, chat:leave — any channel starting with 'chat:'
subscribe('chat:*', (data, channel) => {
  console.log(`Event on ${channel}:`, data)
})

await publish('chat:message', { text: 'Hello' })  // triggers the wildcard subscriber
await publish('chat:typing', { userId: '1' })     // triggers the wildcard subscriber
await publish('notifications', { msg: 'Alert' })  // does NOT trigger

Global wildcard (*)

Receives every event published on any channel:

subscribe('*', (data, channel) => {
  console.log(`[${channel}]`, data)
})

Exact and wildcard together

Exact and wildcard subscribers for the same channel each receive the event once:

subscribe('chat:message', (data) => { /* exact */ })
subscribe('chat:*', (data, channel) => { /* wildcard */ })

await publish('chat:message', { text: 'Hi' }, { includeSelf: true })
// Both callbacks fire exactly once
Wildcard matching is based on channel prefixes. chat:* matches chat:message, chat:typing, and chat:room:message — any channel whose name starts with chat:.

Event Middleware

Middleware runs before any subscriber callbacks and receives a mutable event object. Call next() to continue the pipeline; omit it to block the event entirely.

const { subscribe, publish } = useRealtimeEvents({
  middleware: [
    // Validation — block events that fail a check
    (event, next) => {
      if (isValid(event.data)) next()
    },
    // Transformation — mutate event.data before subscribers see it
    (event, next) => {
      event.data = { ...event.data, receivedAt: Date.now() }
      next()
    },
  ],
})

Middleware is applied to every incoming event for the composable instance that declares it.

Type-Safe Events

Pass an event map as a generic type parameter to get compile-time type checking on channel names and their payloads:

interface AppEvents {
  'chat:message': { text: string; userId: string }
  'chat:typing': { userId: string }
  'user:join': { userId: string; name: string }
}

const { publish, subscribe } = useRealtimeEvents<AppEvents>()

// publish — channel and data are both fully typed
publish('chat:message', { text: 'Hello', userId: '1' })  // ✓
publish('chat:message', { text: 'Hello' })               // ✗ missing userId
publish('unknown:channel', { text: 'Hello' })            // ✗ not in AppEvents

// subscribe — data parameter is automatically typed
subscribe('chat:message', (data) => {
  console.log(data.text, data.userId) // typed as { text: string; userId: string }
})

Subscribe Options

interface UseRealtimeEventsSubscribeOptions {
  /**
   * Whether to receive events that this client published
   * @default false
   */
  includeSelf?: boolean
}

includeSelf

By default, when you publish an event, you don't receive it back. Set includeSelf: true to receive your own events:

const { subscribe, publish } = useRealtimeEvents()

// Won't receive own events (default)
subscribe('chat', (msg) => console.log(msg))
publish('chat', { text: 'Hello' }) // callback NOT triggered

// Will receive own events
subscribe('chat', (msg) => console.log(msg), { includeSelf: true })
publish('chat', { text: 'Hello' }) // callback IS triggered

// Can also set on publish
publish('chat', { text: 'Hello' }, { includeSelf: true })

Examples

Chat Messages

Real-time chat with message history:

Chat.vue
<script setup>
const { subscribe, publish } = useRealtimeEvents()
const messages = ref([])
const input = ref('')
const userId = crypto.randomUUID()

interface Message {
  id: string
  userId: string
  text: string
  timestamp: number
}

subscribe<Message>('chat-messages', (message) => {
  messages.value.push(message)
})

async function sendMessage() {
  if (!input.value.trim()) return

  await publish('chat-messages', {
    id: crypto.randomUUID(),
    userId,
    text: input.value,
    timestamp: Date.now(),
  })

  input.value = ''
}
</script>

<template>
  <div>
    <div class="messages">
      <div
        v-for="msg in messages"
        :key="msg.id"
        :class="msg.userId === userId ? 'own' : 'other'"
      >
        {{ msg.text }}
      </div>
    </div>
    <form @submit.prevent="sendMessage">
      <input v-model="input" placeholder="Type a message..." />
      <button type="submit">Send</button>
    </form>
  </div>
</template>

Toast Notifications

Broadcast notifications to all clients:

Notifications.vue
<script setup>
const toast = useToast()
const { subscribe, publish } = useRealtimeEvents()

interface Notification {
  title: string
  description: string
  color: 'success' | 'info' | 'warning' | 'error'
}

subscribe<Notification>('notifications', (notification) => {
  toast.add({
    title: notification.title,
    description: notification.description,
    color: notification.color,
  })
})

function notifyAll(type: Notification['color']) {
  publish('notifications', {
    title: `${type.charAt(0).toUpperCase() + type.slice(1)} Alert`,
    description: 'This notification was broadcast to all clients.',
    color: type,
  })
}
</script>

<template>
  <div class="flex gap-2">
    <button @click="notifyAll('success')">Success</button>
    <button @click="notifyAll('info')">Info</button>
    <button @click="notifyAll('warning')">Warning</button>
    <button @click="notifyAll('error')">Error</button>
  </div>
</template>

Live Cursors

Show other users' cursor positions:

LiveCursors.vue
<script setup>
const { subscribe, publish } = useRealtimeEvents()
const cursors = ref(new Map())
const myId = crypto.randomUUID()

interface CursorPosition {
  userId: string
  x: number
  y: number
  color: string
}

subscribe<CursorPosition>('cursors', (cursor) => {
  if (cursor.userId !== myId) {
    cursors.value.set(cursor.userId, cursor)
  }
})

function handleMouseMove(event: MouseEvent) {
  publish('cursors', {
    userId: myId,
    x: event.clientX,
    y: event.clientY,
    color: '#' + myId.slice(0, 6),
  })
}

onMounted(() => {
  window.addEventListener('mousemove', handleMouseMove)
})

onUnmounted(() => {
  window.removeEventListener('mousemove', handleMouseMove)
})
</script>

<template>
  <div>
    <div
      v-for="[id, cursor] in cursors"
      :key="id"
      class="cursor"
      :style="{
        left: cursor.x + 'px',
        top: cursor.y + 'px',
        backgroundColor: cursor.color,
      }"
    />
  </div>
</template>

Typing Indicators

Show when other users are typing:

TypingIndicator.vue
<script setup>
const { subscribe, publish } = useRealtimeEvents()
const typingUsers = ref(new Set())
const myId = crypto.randomUUID()

let typingTimeout: ReturnType<typeof setTimeout>

subscribe<{ userId: string; isTyping: boolean }>('typing', ({ userId, isTyping }) => {
  if (userId === myId) return

  if (isTyping) {
    typingUsers.value.add(userId)
  } else {
    typingUsers.value.delete(userId)
  }
})

function onInput() {
  publish('typing', { userId: myId, isTyping: true })

  clearTimeout(typingTimeout)
  typingTimeout = setTimeout(() => {
    publish('typing', { userId: myId, isTyping: false })
  }, 1000)
}
</script>

<template>
  <div>
    <input @input="onInput" placeholder="Type something..." />
    <p v-if="typingUsers.size > 0">
      {{ typingUsers.size }} user(s) typing...
    </p>
  </div>
</template>

Differences from useRealtimeState

FeatureuseRealtimeStateuseRealtimeEvents
PersistenceState persisted on serverEvents are ephemeral
Initial ValueLoaded from server on mountNo initial value
Use CaseShared data (counters, lists)Notifications, messages
API StyleReactive refPub/sub callbacks

Use useRealtimeState when you need persistent, shared state. Use useRealtimeEvents when you want to broadcast ephemeral messages or notifications.