Messaging
Build public real-time chat with channels, reactions, and read receipts.
On this page
DeepSpace ships a small public-chat layer as drop-in schemas plus React hooks. Add the schemas to your app-owned RecordRoom, then compose the UI from useChannels, useMessages, and useReactions.
Add the schemas#
Import schemas from the runtime-neutral deepspace/schema entry point whenever browser or shared code also imports the schema array.
// src/schemas.ts
import type { CollectionSchema } from 'deepspace/schema'
import {
CHANNELS_SCHEMA,
MESSAGES_SCHEMA,
REACTIONS_SCHEMA,
CHANNEL_MEMBERS_SCHEMA,
READ_RECEIPTS_SCHEMA,
} from 'deepspace/schema'
import { usersSchema } from './schemas/users-schema'
export const schemas: CollectionSchema[] = [
usersSchema,
CHANNELS_SCHEMA,
MESSAGES_SCHEMA,
REACTIONS_SCHEMA,
CHANNEL_MEMBERS_SCHEMA,
READ_RECEIPTS_SCHEMA,
]
The five collections are channels, messages, reactions, channel-members, and read-receipts. They live in your app's room; DeepSpace does not create a shared global messaging database.
Build the chat surface#
import { useState } from 'react'
import { useChannels, useMessages, useReactions } from 'deepspace'
export default function ChatApp() {
const { channels, status } = useChannels()
const [activeId, setActiveId] = useState<string>()
if (status === 'loading') return <SkeletonList />
return (
<div className="grid h-screen grid-cols-[240px_1fr]">
<aside>
{channels.map((channel) => (
<button key={channel.recordId} onClick={() => setActiveId(channel.recordId)}>
#{channel.data.name}
</button>
))}
</aside>
<main>{activeId && <ChannelView channelId={activeId} />}</main>
</div>
)
}
function ChannelView({ channelId }: { channelId: string }) {
const { messages, send, softDelete } = useMessages(channelId)
const { toggle, getReactionsForMessage } = useReactions(channelId)
const [draft, setDraft] = useState('')
return (
<>
<ul>
{messages.map((message) => (
<li key={message.recordId}>
<span>{message.data.authorId}</span>
<time>{new Date(message.createdAt).toLocaleTimeString()}</time>
<p>{message.data.deleted ? '[deleted]' : message.data.content}</p>
{getReactionsForMessage(message.recordId).map((reaction) => (
<button
key={reaction.emoji}
onClick={() => toggle(message.recordId, reaction.emoji)}
>
{reaction.emoji} {reaction.count}
</button>
))}
<button onClick={() => softDelete(message.recordId)}>Delete</button>
</li>
))}
</ul>
<form
onSubmit={(event) => {
event.preventDefault()
if (!draft.trim()) return
send(draft)
setDraft('')
}}
>
<input value={draft} onChange={(event) => setDraft(event.target.value)} />
<button>Send</button>
</form>
</>
)
}
The author is derived from the verified session. Sends, edits, reactions, and deletes are fire-and-forget: query state changes only after the room accepts the write and broadcasts it. Handle rejected writes with RecordProvider.onWriteError. Prefer softDelete(messageId) over remove(messageId) so thread links remain coherent.
Create a channel#
All bundled channels are public, so create only needs a name and optional description.
const { create } = useChannels()
const channelId = await create({
name: 'general',
description: 'Company-wide announcements',
})
Membership#
Membership is useful for public opt-in UX. join and leave use confirmed mutations, so their promises settle only after the room accepts or rejects the write.
import { useState } from 'react'
import { useChannelMembers } from 'deepspace'
function MembershipButton({ channelId }: { channelId: string }) {
const { isMember, join, leave } = useChannelMembers(channelId)
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const changeMembership = async () => {
setPending(true)
setError(null)
try {
if (isMember) await leave()
else await join()
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Membership update failed')
} finally {
setPending(false)
}
}
return (
<div>
<button disabled={pending} onClick={() => void changeMembership()}>
{pending ? 'Saving…' : isMember ? 'Leave' : 'Join'}
</button>
{error && <p role="alert">{error}</p>}
</div>
)
}
Membership does not make a channel private. The bundled message schema remains readable to every room member.
Read receipts#
import { useEffect } from 'react'
import { useMessages, useReadReceipts } from 'deepspace'
function ChannelUnread({ channelId }: { channelId: string }) {
const { messages } = useMessages(channelId)
const { markAsRead, getUnreadCount } = useReadReceipts()
useEffect(() => {
markAsRead(channelId)
}, [channelId, messages.length, markAsRead])
return <span>{getUnreadCount(channelId, messages)} unread</span>
}
markAsRead stores the current timestamp for the signed-in user. Receipt rows are private to that user under the bundled schema.
App-specific private messaging#
Keep private messaging inside the app that owns it. Define a collection with immutable participant IDs, set it as collaboratorsField, and use read: 'shared' so the worker—not UI filtering—enforces access. You can give each thread an app-owned room ID such as chat:${threadId} when separate Durable Objects are useful. Expose an authenticated API if another app needs access.
Next steps#
- Messaging reference — exact hook signatures.
- Permissions — server-enforced private rows.
- Presence and cursors — typing indicators and online status.