Search documentation

Search pages and headings

Client Library

Framework Bindings

Reactive useLiveChat adapters for React, Vue and Svelte, all over one framework-agnostic core.

One core, three adapters

The client ships a framework-agnostic store (LiveChatStore, exported from the root entry) that owns every behaviour — dedupe, buffer capping, counting, the status machine. Each framework binding is a thin layer that binds that store to component lifetime and reactivity, so all three behave identically.

Import the adapter from its subpath. React itself never enters your bundle unless you import the React entry:

import { useLiveChat } from '@gettersethya/yt-livechat-client/react'
import { useLiveChat } from '@gettersethya/yt-livechat-client/vue'
import { useLiveChat } from '@gettersethya/yt-livechat-client/svelte'
Subpath Peer dependency Binding
/react react ^18 || ^19 useSyncExternalStore
/vue vue ^3.5 shallowRef + scope disposal
/svelte svelte ^5 svelte/store readable

All peers are optional — install only the one you use:

npm install @gettersethya/yt-livechat-client react
pnpm add @gettersethya/yt-livechat-client react
yarn add @gettersethya/yt-livechat-client react
bun add @gettersethya/yt-livechat-client react

The state shape

Every adapter returns the same LiveChatState snapshot:

Field Type Description
status 'connecting' | 'live' | 'ended' | 'error' Stream state machine
messages readonly MessageSchema[] Newest-last transcript, capped by maxMessages
totalMessages number Every unique message seen this session, including trimmed ones
detail string | null End reason, or "CODE: message" after an error
error ApiHttpError | null The last error object, if any

Duplicate messages are filtered internally (YouTube can resend across polls), so neither the transcript nor the count double-counts.

Options

useLiveChat({ baseUrl, videoUrl }, {
  maxMessages: 5000,
  dedupeWindow: 10000,
})
Option Default Description
maxMessages 5000 Transcript cap; oldest messages drop off the front
dedupeWindow 10000 How many recent ids to remember when filtering resends

The core client's optionsmessageSpacingMs, fetchFn — pass straight through as well.

React

import { useLiveChat } from '@gettersethya/yt-livechat-client/react'

function Chat({ baseUrl, videoUrl }: Props) {
  const { messages, status, detail, totalMessages } = useLiveChat({
    baseUrl,
    videoUrl,
  })

  return (
    <>
      <p>{status}{totalMessages} messages</p>
      <ul>
        {messages.map((message) => (
          <li key={message.id}>{message.author}: {message.message}</li>
        ))}
      </ul>
    </>
  )
}

The connection lives for as long as the component does: started on mount, stopped on unmount, safe under StrictMode's double effects. To follow another video, give the component a new key rather than mutating props mid-flight — the hook binds to one stream per instance.

useVideoId(url) is also exported; it memoizes extractVideoId and returns null for malformed input.

Vue

<script setup lang="ts">
import { useLiveChat } from '@gettersethya/yt-livechat-client/vue'

const props = defineProps<{ baseUrl: string; videoUrl: string }>()

const chat = useLiveChat(props)

console.log(chat.value.status)
</script>

<template>
  <p>{{ chat.totalMessages }} messages — {{ chat.status }}</p>
  <ul>
    <li v-for="message in chat.messages" :key="message.id">
      {{ message.author }}: {{ message.message }}
    </li>
  </ul>
</template>

The returned value is a readonly ref; templates unwrap it automatically. Work is bound to the component scope — starting waits for onMounted, so server-side rendering stays network-silent — and everything stops when the scope disposes.

Vue's version accepts more than the others where it makes sense: useVideoId(videoUrl) takes a string, ref or getter and returns a computed. For the live-chat input itself, treat the stream as keyed to the component and re-mount with a changed :key when the video changes.

Svelte

The Svelte binding is a plain svelte/store readable, so it works with $store auto-subscription in components and with runes alike:

<script lang="ts">
  import { useLiveChat } from '@gettersethya/yt-livechat-client/svelte'

  let { baseUrl, videoUrl }: Props = $props()

  const chat = useLiveChat({ baseUrl, videoUrl })
</script>

<p>{$chat.totalMessages} messages — {$chat.status}</p>
<ul>
  {#each $chat.messages as message (message.id)}
    <li>{message.author}: {message.message}</li>
  {/each}
</ul>

The store starts when first subscribed (i.e. when the template reads $chat) and stops on the final unsubscribe, which is component teardown in practice. Wrap the consumer in an {#key videoUrl} block to move to another video. Because it is a real store you can derive from it: $derived($chat.messages.length) and friends work unchanged.

useVideoId(url) mirrors the other adapters as a pure helper around extractVideoId.

Building your own binding

If none of these fit — Solid, Angular signals, a terminal UI — compose the exported core directly. It exposes exactly four members:

import { LiveChatStore } from '@gettersethya/yt-livechat-client'

const store = new LiveChatStore({ baseUrl, videoUrl })

store.subscribe((state) => render(state)) // returns an unsubscribe fn
void store.start()                        // resolves when the stream ends
store.stop()                              // cancels; safe before/during start

Your framework layer only has to translate subscribe/getState into its reactivity primitive and call start()/stop() on mount/unmount. The three adapters above are each ~15 lines doing precisely that — read their source as reference implementations.