Pular para o conteúdo
📖 COOKBOOK

Realtime chat em 40 linhas.

Combinação dos 3 recursos de realtime: subscribe pra mensagens novas (postgres_changes), presence pra mostrar quem está online, broadcast pra typing indicator. Padrão production-ready.

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 + tabela room_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

schema.sql
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

schema.sql
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

src/hooks/useChat.ts
'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

src/components/Chat.tsx
'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".

Essa página ajudou?