Using with React and Vue
The Web SDK's core package doesn't depend on any front-end framework: everything that changes is exposed as a snapshot you can subscribe to, so it works with any framework. React and Vue also have official binding packages that create, subscribe to and dispose of lists within the component lifecycle, and handle React StrictMode double mounting, hot reloading and server-side rendering:
| Package | Contents | Supported versions |
|---|---|---|
@deeprespond/im-web-react | DRProvider and hooks | React 18, 19 |
@deeprespond/im-web-vue | A plugin and composables | Vue 3.3 and later |
The bindings contain no UI components; they only handle data. They're released together with the core package under the same version number, so install matching versions. For Svelte, Angular and other frameworks, use the core package directly; see Without the bindings.
The repository includes runnable demos built on these two bindings (the same feature list implemented in React and in Vue); see React / Vue project demos, or download the source package and run it.
For a ready-made chat UI, use React UIKit or Vue UIKit. They're built on the core SDK and the bindings, provide conversation, message, friend and group components, and are published to public npm (version 1.1.1), with source and local tgz packages also available. Vue UIKit doesn't depend on React at runtime.
| You want to | See |
|---|---|
| Decide where to create the client (single-page app, Next.js / Nuxt) | Where the client lives |
| Show sign-in state, the conversation list and message list in React | React |
| Use it in Vue | Vue |
| Know what hooks return during server-side rendering | Values before the client exists |
| Use Svelte, Angular or another framework | Without the bindings |
| Avoid common pitfalls (StrictMode, long lists, hidden panels) | Things to watch |
Where the client lives
Single-page apps
Create the client once in a module and share it across the app. Don't create it in components:
// src/im.ts
import { createDRClient } from '@deeprespond/im-web';
export const im = createDRClient({ appKey: '1575529652#demo', apiUrl: 'https://im.example.com' });
// 热更新会重新执行这个模块:先释放旧的客户端,新的才能创建(webpack 为 import.meta.webpackHot)
if (import.meta.hot) import.meta.hot.dispose(() => void im.destroy());Then hand it to the bindings: <DRProvider client={im}> in React, app.use(createDRPlugin({ client: im })) in Vue.
Server-side rendering
Frameworks such as Next.js and Nuxt run page code on the server, but the client can only be created in a browser (calling createDRClient on the server throws unsupported). In that case, don't create it at module top level. Pass the options to the bindings instead, which create the client in the browser and destroy it on unmount: <DRProvider options={...}> in React, createDRPlugin({ options: ... }) in Vue. During server-side rendering, hooks and composables return empty values (see Values before the client exists) and update automatically once the client is created in the browser.
In the Next.js App Router, put DRProvider inside your own 'use client' component: functions in the options (ticketProvider, logger.sink) can't be passed from server components to client components.
Creating it in a component
If you have to create the client in a component (for example when the AppKey is only known after sign-in), create it in an effect and destroy() it in the cleanup, or use DRProvider's options. React StrictMode creates, destroys and creates again in development, and that's fine: you can create again right after destroy(). Don't create it in the render function or in a useState or useMemo initializer: StrictMode calls those twice, and the second creation happens before the first client is destroyed, throwing invalid_state (client_exists).
React
npm install @deeprespond/im-web @deeprespond/im-web-reactProvide the client
import { DRProvider } from '@deeprespond/im-web-react';
// im 为上文 src/im.ts 中创建的客户端(import { im } from './im')
// 在入口中渲染它:createRoot(document.getElementById('root')!).render(<Root />)
export function Root() {
return (
<DRProvider client={im}>
<App />
</DRProvider>
);
}For server-rendered apps, use options instead:
'use client';
import type { ReactNode } from 'react';
import { DRProvider } from '@deeprespond/im-web-react';
const options = { appKey: '1575529652#demo', apiUrl: 'https://im.example.com' };
export function IMProvider({ children }: { children: ReactNode }) {
return <DRProvider options={options}>{children}</DRProvider>;
}options is read once on mount; later changes have no effect. To switch to another app, change DRProvider's key so it remounts.
Sign-in state
useAuthState() returns the sign-in state, which decides whether to show the login page or the main UI. It's null before the client exists (server-side rendering, first render), when it isn't known yet whether the user is signed in, so show a loading state:
import { useAuthState, useConnectionState } from '@deeprespond/im-web-react';
export function Root() {
const auth = useAuthState();
const connection = useConnectionState();
if (auth === null) return <p>加载中…</p>;
if (auth.kind === 'signed_out' || auth.kind === 'ended') return <LoginPage />;
return (
<>
{connection.kind !== 'ready' && <p className="banner">{connection.kind === 'offline' ? '网络不可用' : '连接中…'}</p>}
<MainPage />
</>
);
}On the login page, get the client with useDRClient() and sign in:
import { useDRClient } from '@deeprespond/im-web-react';
export function LoginPage() {
const im = useDRClient();
const login = async () => {
const res = await fetch('/api/im-ticket', { method: 'POST', credentials: 'include' });
const { ticket } = (await res.json()) as { ticket: string };
await im?.auth.loginWithTicket({ ticket });
};
return <button disabled={!im} onClick={() => void login()}>登录</button>;
}Conversation list
useLiveList(create, deps) calls create(im) in an effect to get a list, and disposes it on unmount. It doesn't call create before sign-in, and recreates the list for the new session after sign-out or a user switch:
import { memo } from 'react';
import type { ConversationView } from '@deeprespond/im-web';
import { useLiveList, useConversationTitle, useUnreadTotal } from '@deeprespond/im-web-react';
export function ConversationList({ onOpen }: { onOpen: (key: string) => void }) {
const { snapshot, list } = useLiveList<ConversationView>((im) => im.conversations.list(), []);
const unread = useUnreadTotal();
return (
<div>
<h3>消息{unread > 0 && `(${unread})`}</h3>
{snapshot.loading && snapshot.items.length === 0 && <p>加载中…</p>}
{snapshot.items.map((c) => (
<ConversationRow key={c.conversation_key} view={c} onOpen={onOpen} />
))}
{snapshot.hasMore && <button onClick={() => void list?.loadMore()}>加载更多</button>}
</div>
);
}
// 没有变化的会话仍是原来的对象,memo 的行组件不会重新渲染
const ConversationRow = memo(function ConversationRow({ view, onOpen }: { view: ConversationView; onOpen: (key: string) => void }) {
const title = useConversationTitle(view);
return (
<div onClick={() => onOpen(view.conversation_key)}>
{title} {view.unread_count > 0 && <b>{view.unread_count}</b>}
</div>
);
});Give useLiveList the item type (such as useLiveList<ConversationView>), or the snapshot's item type can't be inferred. The same goes for other lists: friends are useLiveList<Friend>((im) => im.friends.list(), []) and group members useLiveList<GroupMember>((im) => im.groups.members(groupId), [groupId]). When deps change, the old list is disposed and a new one created.
The conversation title isn't part of the conversation object: it comes from friend remarks, user profiles and group names, and the conversation object doesn't change when they do. So memoized row components should show names with useConversationTitle and useDisplayName, which re-render when names change.
Message list and sending
useMessageList(conversation_key, opts?) opens a conversation's message list. When conversation_key changes, it disposes the old list and opens the new one; it disposes on unmount:
import { useState } from 'react';
import { DRError } from '@deeprespond/im-web';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
import { useDRClient, useDREvent, useDisplayName, useMessageList, useTyping } from '@deeprespond/im-web-react';
export function Chat({ conversationKey }: { conversationKey: string }) {
const im = useDRClient();
const { snapshot, list } = useMessageList(conversationKey);
const typing = useTyping(conversationKey);
const [text, setText] = useState('');
useDREvent('message.sendFailed', (p) => {
if (p.conversation_key === conversationKey) alert(describeError(p.error));
});
const send = async () => {
if (!im || !text) return;
setText('');
try {
await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text } });
} catch (err) {
if (err instanceof DRError) alert(describeError(err));
}
};
return (
<div>
{snapshot.hasOlder && <button onClick={() => void list?.loadOlder()}>查看更早的消息</button>}
{snapshot.items.map((m) => (
<MessageRow key={m.client_msg_id ?? String(m.seq)} sender={m.sender} text={m.type === 'text' ? String(m.body?.text ?? '') : `[${m.type}]`} status={m._local.status} />
))}
{typing.length > 0 && <p>对方正在输入…</p>}
<input value={text} onChange={(e) => { setText(e.target.value); im?.conversations.typing(conversationKey); }} />
<button onClick={() => void send()}>发送</button>
</div>
);
}
function MessageRow({ sender, text, status }: { sender: string | null; text: string; status: string }) {
const name = useDisplayName(sender);
return <p>{name}:{text}{status !== 'sent' && `(${status})`}</p>;
}- When a draft conversation (a one-to-one chat with no messages yet) sends its first message and becomes a real conversation, the list switches in place; the
conversation_keychanging to the new conversation ID afterward doesn't reopen it. - Changing only
opts.around_seqjumps the list to that message (list.jumpTo()) without reopening. - When the list is hidden but not unmounted (such as a panel switched away in a tabbed UI), call
list.setViewing({ visible: false }). Otherwise, withautoMarkReadon, messages the user never saw get marked as read.
Events, presence and chat rooms
import { useDREvent, usePresence, useChatroom, useHandle, useDRClient, useDRState } from '@deeprespond/im-web-react';
export function Notifier() {
// 总是调用最新一次渲染的函数,不会因为它变化而重新订阅
useDREvent('message.received', ({ message, alert }) => {
if (alert && document.hidden) new Notification('新消息', { body: message.sender ?? '' });
});
return null;
}
export function OnlineDots({ usernames }: { usernames: string[] }) {
const presence = usePresence(usernames); // 名单按内容比较,每次渲染传入新数组不会重新订阅
return <>{usernames.map((u) => <span key={u}>{u}{presence.get(u)?.online ? '(在线)' : ''}</span>)}</>;
}
export function LiveRoom({ roomId }: { roomId: string }) {
const { snapshot, error } = useChatroom(roomId); // 挂载时进入,卸载或 roomId 变化时离开
if (error) return <p>进入失败</p>;
if (!snapshot) return <p>正在进入…</p>;
return <p>{snapshot.info.name}:{snapshot.messages.length} 条消息</p>;
}
export function CurrentCall() {
const current = useDRState((im) => im.calls.current, ['call.incoming', 'call.changed'], null);
const snapshot = useHandle(current); // 通话的快照;没有通话时为 null
return snapshot ? <p>通话状态:{snapshot.call.status}</p> : null;
}useDRState(read, events, fallback?) reads any synchronous value: it calls read again when any of events fires, and skips re-rendering when the result hasn't changed (Object.is). For which event goes with which value, see Reading synchronous values with events.
All React exports
| Export | Description |
|---|---|
DRProvider | Provides the client. Takes either client (an existing client) or options (DRClientOptions; it creates the client in the browser and calls destroy() on unmount) |
useDRClient() | The current client, DRClient | null; with options, null until created |
useLiveList(create, deps) | Returns { snapshot, list }. Calls create(im) in an effect and calls dispose() when deps change, the session changes or it unmounts. Doesn't call create while signed out |
useMessageList(conversation_key, opts?) | Returns { snapshot, list }, opening im.messages.open(conversation_key, opts). Doesn't open anything when conversation_key is null or undefined |
useHandle(handle) | The snapshot of a chat room or call object; null when handle is null |
useChatroom(room_id, opts?) | Enters the chat room on mount and leaves on unmount or when room_id changes (StrictMode double mounting doesn't leave). Returns { handle, snapshot, error } |
useDRState(read, events, fallback?) | Reads a synchronous value, re-reading when any of events fires, and always on auth.stateChanged, auth.userSwitched and storage.reset. Returns fallback before the client exists |
useDREvent(event, listener) | Subscribes to an event and unsubscribes on unmount |
usePresence(usernames) | Subscribes to these people's presence and unsubscribes on unmount or when the list changes. Returns Map<string, PresenceState | undefined>. If presence isn't on for the app, it doesn't subscribe and every value is undefined |
useAuthState() | Sign-in state, AuthState | null |
useConnectionState() | Connection state, ConnectionState |
useConfig() | Runtime configuration, ClientConfig | null |
useMe() | The user's own profile, SelfProfile | null |
useUnreadTotal(opts?) | Unread total, number. With opts.excludeMuted: true, muted conversations don't count |
useUser(username) | Another user's profile, UserProfile | undefined, fetched in the background if not stored locally |
useFriendRequestUnread() | Number of unread friend requests, number |
useTyping(conversation_key) | Who's typing in the conversation, readonly string[] |
useDisplayName(username, opts?) | The user's display name, string | undefined (first non-empty of friend remark, group nickname, nickname and username; the group nickname counts when opts.group_id is given). Updates when name-related data changes |
useConversationTitle(view) | The conversation's title, string | undefined. Updates when name-related data changes |
useChannel(handle?) | Snapshot of a standalone channel; see Channel state |
useGameRoom(room?), useGameEvent(room, event, listener) | Game room snapshot and events; see Game room state |
The hooks use useSyncExternalStore internally, so data read within one render is consistent under concurrent rendering.
Vue
npm install @deeprespond/im-web @deeprespond/im-web-vueInstall the plugin
// main.ts。App 为根组件(import App from './App.vue'),im 为上文 src/im.ts 中创建的客户端
import { createApp } from 'vue';
import { createDRPlugin } from '@deeprespond/im-web-vue';
createApp(App).use(createDRPlugin({ client: im })).mount('#app');For Nuxt and other server-rendered apps, use options instead. The plugin creates the client in the browser and calls destroy() when the app unmounts:
import { createDRPlugin } from '@deeprespond/im-web-vue';
app.use(createDRPlugin({ options: { appKey: '1575529652#demo', apiUrl: 'https://im.example.com' } }));Use it in components
The composables map one-to-one to the React hooks, with the same names. Returned snapshots and state are read-only ShallowRefs you can use directly in templates:
<script setup lang="ts">
import { ref } from 'vue';
import type { ConversationView } from '@deeprespond/im-web';
import { useAuthState, useConnectionState, useDRClient, useDREvent, useLiveList, useMessageList, useUnreadTotal } from '@deeprespond/im-web-vue';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
const im = useDRClient();
const auth = useAuthState();
const connection = useConnectionState();
const unread = useUnreadTotal();
const { snapshot: conversations, list } = useLiveList<ConversationView>((c) => c.conversations.list());
const current = ref<string | null>(null);
const { snapshot: messages } = useMessageList(current); // current 变化时释放旧的列表、打开新的
const text = ref('');
async function send() {
if (!im.value || !current.value || !text.value) return;
const body = { text: text.value };
text.value = '';
await im.value.messages.send({ conversation_key: current.value }, { type: 'text', body });
}
useDREvent('message.sendFailed', (p) => alert(describeError(p.error)));
</script>
<template>
<p v-if="auth === null">加载中…</p>
<p v-else-if="auth.kind !== 'signed_in'">请登录</p>
<div v-else>
<p v-if="connection.kind !== 'ready'">连接中…</p>
<h3>消息({{ unread }})</h3>
<div v-for="c in conversations.items" :key="c.conversation_key" @click="current = c.conversation_key">
{{ c.peer ?? c.group_id }} {{ c.unread_count || '' }}
</div>
<button v-if="conversations.hasMore" @click="list?.loadMore()">加载更多</button>
<div v-if="current">
<p v-for="m in messages.items" :key="m.client_msg_id ?? m.seq ?? ''">{{ m.sender }}:{{ m.body?.text }}</p>
<input v-model="text" @keyup.enter="send"> <button @click="send">发送</button>
</div>
</div>
</template>- Arguments can be plain values,
refs or getters (such asuseMessageList(() => props.conversationKey)). When they change, the old object is disposed and a new one created. useLiveList(create)has nodepsargument:createruns in a dependency-tracking scope, and when reactive values it reads (such asprops.groupId) change, the list is recreated automatically, as inuseLiveList<GroupMember>((c) => c.groups.members(props.groupId)). Give the item type here too.- Lists are created in
onMountedand disposed when the scope is destroyed, so they aren't created during server-side rendering;listonly has a value after mounting. - Components cached by
<KeepAlive>aren't destroyed, so their lists stay open.useMessageListreports the list as not shown when deactivated and restores it when reactivated, so the conversation isn't counted as "being viewed" just because the page is cached. - For conversation titles and display names, use
useConversationTitle(view)anduseDisplayName(username). They returncomputedvalues that update when name-related data changes.
All Vue exports
| Export | Description |
|---|---|
createDRPlugin({ client?, options? }) | The plugin. Takes either client (an existing client) or options (creates the client in the browser and calls destroy() when the app unmounts) |
useDRClient() | The current client, ShallowRef<DRClient | null> |
useLiveList(create) | Returns { snapshot, list }, both read-only ShallowRefs. Disposed when reactive values read in create change, the session changes or the scope is destroyed |
useMessageList(conversation_key, opts?) | Returns { snapshot, list }; arguments can be values, refs or getters |
useHandle(handle) | The snapshot of a chat room or call object; handle can be a value, ref or getter |
useChatroom(room_id, opts?) | Enters the chat room on mount and leaves when the scope is destroyed or room_id changes. Returns { handle, snapshot, error } |
useDRState(read, events, fallback?) | Reads a synchronous value and returns a read-only ShallowRef; same rules as React |
useDREvent(event, listener) | Subscribes to an event and unsubscribes when the scope is destroyed |
usePresence(usernames) | Subscribes to presence and returns ShallowRef<Map<string, PresenceState | undefined>>; usernames can be a value, ref or getter |
useAuthState(), useConnectionState(), useConfig(), useMe(), useUnreadTotal(opts?), useUser(username), useFriendRequestUnread(), useTyping(conversation_key) | Same as React, returning read-only ShallowRefs |
useDisplayName(username, opts?), useConversationTitle(view) | Same as React, returning computed; arguments can be values, refs or getters |
useChannel(handle?) | Snapshot of a standalone channel as a read-only Ref; see Channel state |
useGameRoom(room?), useGameEvent(room, event, listener) | Game room snapshot (a read-only ShallowRef) and events; room can be a value, ref or getter; see Game room state |
Composables can also be used outside components in an effectScope; they start immediately and are disposed when the scope stops.
Values before the client exists
During server-side rendering, and before a client is created from options, hooks and composables return these values and update automatically once the client exists:
| Function | Value |
|---|---|
useLiveList | Snapshot { items: [], hasMore: false, loading: true }; list is null |
useMessageList | Snapshot { items: [], gaps: [], hasOlder: false, hasNewer: false, loading: 'initial', pinned: [] }; list is null |
useHandle, useChatroom | Snapshot is null |
useAuthState() | null: it isn't known yet whether the user is signed in, so show a loading state, not the login page |
useConnectionState() | { kind: 'idle' } |
useConfig(), useMe() | null |
useUnreadTotal(), useFriendRequestUnread() | 0 |
useTyping() | Empty array |
useUser(), useDisplayName(), useConversationTitle() | undefined |
usePresence() | Every value is undefined |
When there is a client but nobody is signed in, state functions read the client's actual values (useAuthState() is { kind: 'signed_out' }, so show the login page). List functions don't create lists and return an empty snapshot that isn't loading: { items: [], hasMore: false, loading: false } for useLiveList, and loading: 'none' for useMessageList. These empty snapshots are the same object every time.
Without the bindings
Every subscribable object (LiveList, MessageList, chat room and call objects) provides getSnapshot() and subscribe(listener); see Live lists for the rules. That works with any framework. The key points: create lists in a side effect and dispose() them in the cleanup, and recreate them per session (lists are disposed after sign-out or a user switch).
React:
import { useEffect, useState, useSyncExternalStore } from 'react';
import type { ConversationView, LiveList, LiveListSnapshot } from '@deeprespond/im-web';
const EMPTY: LiveListSnapshot<ConversationView> = Object.freeze({ items: Object.freeze([]), hasMore: false, loading: true });
const noSubscribe = () => () => {};
// 订阅函数定义在组件之外,否则每次渲染都会重新订阅
const subscribeAuth = (cb: () => void) => im.on('auth.stateChanged', cb);
export function ConversationList() {
// 同步读取的值:订阅对应的事件、读取属性;服务端渲染时为 null
const sessionId = useSyncExternalStore(subscribeAuth, () => im.auth.currentUser?.session_id ?? null, () => null);
const [list, setList] = useState<LiveList<ConversationView> | null>(null);
useEffect(() => {
if (!sessionId) return; // 未登录时 list() 抛出 not_signed_in
const l = im.conversations.list(); // 在 effect 中创建,不在渲染中创建
setList(l);
return () => {
l.dispose();
setList(null);
};
}, [sessionId]);
const snapshot = useSyncExternalStore(list?.subscribe ?? noSubscribe, list?.getSnapshot ?? (() => EMPTY), () => EMPTY);
return <ul>{snapshot.items.map((c) => <li key={c.conversation_key}>{c.unread_count}</li>)}</ul>;
}Vue (single-page apps; with server-side rendering, create in onMounted instead):
import { onScopeDispose, shallowRef } from 'vue';
const list = im.conversations.list();
const snapshot = shallowRef(list.getSnapshot());
const off = list.subscribe(() => {
snapshot.value = list.getSnapshot();
});
onScopeDispose(() => {
off();
list.dispose();
});Svelte:
import { readable } from 'svelte/store';
const list = im.conversations.list();
export const conversations = readable(list.getSnapshot(), (set) => {
set(list.getSnapshot());
return list.subscribe(() => set(list.getSnapshot()));
});
// 组件销毁时另外调用 list.dispose()Angular: keep the snapshot in a signal() and update it in the subscribe callback; unsubscribe and call dispose() in DestroyRef.onDestroy.
Things to watch
- Don't create lists during render. Each call to
im.conversations.list()orim.messages.open()creates a new list, so create them in a side effect (or use the bindings) anddispose()them in the cleanup. A message list that's never disposed keeps the SDK backfilling messages for it and keeps the conversation counted as "being viewed". - StrictMode. In development, components mount, unmount and mount again. The bindings and the "create in an effect, dispose in the cleanup" pattern both handle this correctly;
useChatroomdoesn't leave the room because of it. - Vue reactivity. SDK objects (the client, lists, chat room and call objects) aren't extensible, and snapshots and data objects are frozen. Putting them in
reforreactivedoesn't proxy them and doesn't cause errors, but it doesn't make them reactive either; still get data throughsubscribeor the composables. - Long lists. The conversation list holds up to
conversationWindowitems (1000 by default), and message lists grow as the user pages back, so use virtual scrolling. Unchanged items keep their objects, so row components can be cached by reference (React.memo, Vue'sv-memo). But show names in rows withuseDisplayNameanduseConversationTitle, or cached rows won't update when names change. - Hidden lists. For a message list that's off-screen but not disposed (such as a panel switched away in a tabbed UI), call
list.setViewing({ visible: false }), and set it back totruewhen shown. In Vue,useMessageListhandles<KeepAlive>automatically. In React 19.2 and later,<Activity>runs effect cleanups when hiding, so the list is already disposed and needs nothing extra. - Multiple tabs. Each tab has its own client and lists, and the SDK syncs data between tabs; the bindings need nothing extra.
Channel state
useChannel() subscribes to the state of a standalone channel:
| Binding | Signature | Returns |
|---|---|---|
| React | useChannel(handle?: ChannelHandle | null) | ChannelSnapshot | null |
| Vue | useChannel(handle?); the argument can be a value, ref or getter | A read-only Ref |
Without handle, it subscribes to the current client's channel.
import { useChannel } from '@deeprespond/im-web-react';
function ChannelStatus() {
const snapshot = useChannel();
return <span>{snapshot?.session.status ?? '未加入频道'}</span>;
}import { useChannel } from '@deeprespond/im-web-vue';
const snapshot = useChannel();
// 模板读取 snapshot;脚本读取 snapshot.value。- As with the other hooks, the component must be inside
DRProvider, or in a Vue app withcreateDRPlugininstalled. useChannelonly reads state. It doesn't join a channel or turn on the camera or microphone. Calljoin()from a user action; before the page unmounts, callleave()and clean up the elements playing audio and video. See Standalone channels.
Game room state
useGameRoom() subscribes to the state of a game room, and useGameEvent() to its events:
| Binding | Signature | Returns |
|---|---|---|
| React | useGameRoom(room?: GameRoom | null) | GameRoomSnapshot | null |
| React | useGameEvent(room, event, listener) | Nothing. Unsubscribes when the room changes or on unmount, and always calls the listener from the latest render |
| Vue | useGameRoom(room?); the argument can be a value, ref or getter | A read-only ShallowRef |
| Vue | useGameEvent(room, event, listener) | Nothing. Unsubscribes when the room changes or the scope is disposed |
When room is omitted (or undefined for useGameEvent) they follow im.game.current and switch automatically when you join a room or the room ends; with null they return null and don't subscribe. event is frame, custom, member, roomState, snapshot, state or close.
import { useGameEvent, useGameRoom } from '@deeprespond/im-web-react';
function MatchStatus() {
const room = useGameRoom();
useGameEvent(undefined, 'member', (e) => console.log(e.username, e.type));
if (!room) return <span>未加入对局</span>;
return <span>座位 {room.seat} · {room.state} · 延迟 {room.rtt ?? '-'} ms</span>;
}import { useGameEvent, useGameRoom } from '@deeprespond/im-web-vue';
const room = useGameRoom();
useGameEvent(undefined, 'close', (e) => console.log('对局结束', e.reason));
// 模板读取 room;脚本读取 room.value。- The snapshot contains the connection state, room state, seat, members and latency, and components re-render when it changes. The frame number isn't in the snapshot (it changes every frame); receive per-frame data with
useGameEvent(room, 'frame', …)to avoid re-rendering every frame. useGameRoomonly reads state and never joins a room. Callim.game.join()on a user action androom.leave()when leaving the match; see Game rooms · Web client.
