O que vamos fazer
Chat completo combinando os 3 modos do Realtime do SuperDB:
- Postgres changes — subscribe a INSERTs na tabela
messages. Quando alguém manda mensagem, todos no room recebem em <100ms. - Presence — lista de "quem está online no room", sincronizada via CRDT. Some sozinha quando o user fecha a aba.
- Broadcast — eventos efêmeros (não persistem no banco). Usado pro typing indicator: "Fulano está digitando…".
Pré-requisitos
- Projeto SuperDB com Auth + Realtime ligados (padrão).
- Tabela
messages+ tabelaroom_members(multi-tenant scoped no schema do projeto). - Replication ativada na
messages(Studio → Database → Replication → marcar a tabela).
Passo a passo
1. Schema do banco
create table proj_X.messages (
id uuid primary key default gen_random_uuid(),
room_id uuid not null,
user_id uuid not null,
content text not null,
created_at timestamptz default now()
);
create table proj_X.room_members (
room_id uuid not null,
user_id uuid not null,
primary key (room_id, user_id)
);
create index on proj_X.messages (room_id, created_at desc);
2. RLS — só membros do room
alter table proj_X.messages enable row level security;
-- SELECT: só membros do room veem
create policy msg_member_select on proj_X.messages
for select to authenticated
using (exists (
select 1 from proj_X.room_members
where room_id = messages.room_id and user_id = auth.uid()
));
-- INSERT: só pode mandar como você mesmo, e tem que ser membro
create policy msg_member_insert on proj_X.messages
for insert to authenticated
with check (
user_id = auth.uid()
and exists (
select 1 from proj_X.room_members
where room_id = messages.room_id and user_id = auth.uid()
)
);
3. Hook React com os 3 modos
'use client'
import { useEffect, useState, useRef } from 'react'
import { db } from '@/lib/superdb'
import { useUser } from '@/lib/auth'
type Msg = { id: string; user_id: string; content: string; created_at: string }
type Presence = { user_id: string; name: string }
export function useChat(roomId: string) {
const user = useUser()
const [messages, setMessages] = useState<Msg[]>([])
const [online, setOnline] = useState<Presence[]>([])
const [typing, setTyping] = useState<string[]>([])
const channelRef = useRef<ReturnType<typeof db.channel> | null>(null)
useEffect(() => {
if (!user) return
// 1. Histórico inicial
db.from('messages')
.select('*').eq('room_id', roomId)
.order('created_at', { ascending: true }).limit(50)
.then(({ data }) => data && setMessages(data))
const channel = db.channel(`room:${roomId}`, {
config: { presence: { key: user.id } },
})
// 2. postgres_changes — novas mensagens
channel.on(
'postgres_changes',
{ event: 'INSERT', schema: 'proj_X', table: 'messages',
filter: `room_id=eq.${roomId}` },
({ new: msg }) => setMessages((prev) => [...prev, msg as Msg]),
)
// 3. Presence — quem tá online
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<Presence>()
setOnline(Object.values(state).flat())
})
// 4. Broadcast — typing indicator
channel.on('broadcast', { event: 'typing' }, ({ payload }) => {
setTyping((prev) =>
prev.includes(payload.user_id) ? prev : [...prev, payload.user_id])
setTimeout(() => setTyping((prev) =>
prev.filter((id) => id !== payload.user_id)), 2000)
})
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ user_id: user.id, name: user.user_metadata.name })
}
})
channelRef.current = channel
return () => { channel.unsubscribe() }
}, [roomId, user])
return {
messages, online, typing,
sendMessage: (content: string) =>
db.from('messages').insert({ room_id: roomId, content }),
notifyTyping: () => channelRef.current?.send({
type: 'broadcast', event: 'typing', payload: { user_id: user!.id },
}),
}
}
Dica: faça debounce do notifyTyping() em 1s no componente que chama o hook. Sem debounce, cada keystroke vira broadcast — barulho na rede e nos listeners.
4. Componente que usa o hook
'use client'
import { useState, useRef } from 'react'
import { useChat } from '@/hooks/useChat'
export function Chat({ roomId }: { roomId: string }) {
const { messages, online, typing, sendMessage, notifyTyping } = useChat(roomId)
const [text, setText] = useState('')
const typingTimer = useRef<number>()
function onType(v: string) {
setText(v)
if (typingTimer.current) clearTimeout(typingTimer.current)
typingTimer.current = window.setTimeout(notifyTyping, 1000)
}
return (
<div>
<aside>Online: {online.map((u) => u.name).join(', ')}</aside>
<ul>{messages.map((m) => <li key={m.id}>{m.content}</li>)}</ul>
{typing.length > 0 && <p>{typing.length} digitando…</p>}
<input value={text} onChange={(e) => onType(e.target.value)} />
<button onClick={() => { sendMessage(text); setText('') }}>Enviar</button>
</div>
)
}
Resultado
Chat funcional com:
- Mensagens persistidas no Postgres e sincronizadas em <100ms entre clients.
- Lista de online auto-sincronizada — não precisa ping ou cleanup manual.
- Typing indicator que aparece e desaparece sozinho (TTL 2s).
- RLS garantindo que ninguém de fora do room recebe as mensagens.
Variações
Typing com nome (não só ID)
No payload do broadcast, mande { user_id, name }. Aí o componente mostra "Felipe está digitando" em vez de UUID.
Histórico paginado (scroll up)
Trocar limit(50) por scroll infinito: ao chegar no topo, pedir .lt('created_at', oldestVisible) e dar prepend.
Reações em mensagens
Tabela message_reactions (message_id, user_id, emoji). Subscribe nos INSERTs dela também (mesmo channel, segundo .on()). Cada reação aparece em <100ms.
Read receipts
Tabela message_reads (message_id, user_id, read_at). Quando o user vê a mensagem na tela (intersection observer), insere. Outro subscribe propaga "✓✓" pro remetente.
Erros comuns
Esquecer .subscribe(): sem isso o channel é só configuração — não conecta. Os .on() não disparam nunca. Sempre encadeie .subscribe() no final.
Múltiplos db.channel() com mesmo nome: em useEffect sem cleanup, cada render cria channel novo — os antigos viram zumbis e recebem duplicados. Sempre return () => channel.unsubscribe().
Presence sem track() antes do sync: se você só faz .on('presence', 'sync') mas nunca chama track(), sua própria presença não vai pro state — e o sync inicial vem vazio.
Broadcast sem filtro de event: se tem broadcast pra typing E pra "novo emoji", e você não filtra pelo event em .on('broadcast', { event: 'typing' }, ...), o listener recebe tudo e o switch vira espaguete.
Replication desligada: se você não marcou a tabela em Database → Replication, o INSERT no banco funciona mas o evento nunca chega no channel. Sintoma: "manda mensagem, vê só depois de F5".