Skip to main content
Shipnative includes ready-to-use hooks for common realtime patterns like chat, presence tracking, and live updates.

Backend Comparison

FeatureSupabaseConvex
Realtime MechanismPostgreSQL ChangesBuilt-in reactivity
Setup RequiredEnable per tableAutomatic
SubscriptionsManual setupAutomatic with useQuery
Typing IndicatorsVia Presence channelVia mutations
PresenceVia Presence APIVia queries

Quick Start

import { useRealtimeMessages } from '@/hooks/useRealtimeMessages'

function ChatRoom({ channelId }: { channelId: string }) {
  const { messages, sendMessage, isConnected } = useRealtimeMessages({ channelId })

  return (
    <View>
      {messages.map(msg => <Text key={msg.id}>{msg.content}</Text>)}
      <Button onPress={() => sendMessage('Hello!')} title="Send" />
    </View>
  )
}

Chat with Supabase

useRealtimeMessages

Full-featured chat hook with typing indicators.
const {
  messages,           // Current messages
  sendMessage,        // Send a new message
  updateMessage,      // Edit a message
  deleteMessage,      // Delete a message
  typingUsers,        // Users currently typing
  setTyping,          // Broadcast typing state
  isConnected,        // Connection status
  loading,            // Loading state
  error,              // Error state
  refresh,            // Refresh messages
} = useRealtimeMessages({
  channelId: 'room-123',
  maxMessages: 100,
  onNewMessage: (msg) => playNotificationSound(),
  onTypingChange: (users) => console.log('Typing:', users),
})
function ChatRoom({ channelId }: { channelId: string }) {
  const [inputText, setInputText] = useState('')
  const {
    messages,
    sendMessage,
    typingUsers,
    setTyping,
    isConnected,
    loading,
  } = useRealtimeMessages({
    channelId,
    onNewMessage: () => Haptics.impactAsync(),
  })

  const handleSend = async () => {
    if (!inputText.trim()) return
    const { error } = await sendMessage(inputText)
    if (!error) setInputText('')
  }

  const handleInputChange = (text: string) => {
    setInputText(text)
    setTyping(text.length > 0)
  }

  if (loading) return <LoadingSpinner />

  return (
    <View style={{ flex: 1 }}>
      {!isConnected && <Banner text="Reconnecting..." />}

      <FlatList
        data={messages}
        renderItem={({ item }) => (
          <MessageBubble
            content={item.content}
            isOwn={item.user_id === currentUserId}
            timestamp={item.created_at}
          />
        )}
        keyExtractor={(item) => item.id}
        inverted
      />

      {typingUsers.length > 0 && (
        <Text style={styles.typing}>
          {typingUsers.join(', ')} typing...
        </Text>
      )}

      <View style={styles.inputRow}>
        <TextInput
          value={inputText}
          onChangeText={handleInputChange}
          placeholder="Type a message..."
        />
        <Button onPress={handleSend} title="Send" />
      </View>
    </View>
  )
}

useRealtimePresence

Track online users with status indicators.
const {
  presentUsers,       // List of online users
  userCount,          // Number of users online
  isConnected,        // Connection status
  updateStatus,       // Update your status
  updateCustomData,   // Update custom presence data
  isUserOnline,       // Check if specific user is online
  getUserPresence,    // Get user's presence state
} = useRealtimePresence({
  channelName: 'room:123',
  initialStatus: 'online',
  customData: { currentScreen: 'chat' },
  onUserJoin: (user) => console.log(`${user.user_id} joined`),
  onUserLeave: (userId) => console.log(`${userId} left`),
})

useRealtimeSubscription

Generic hook for subscribing to any table changes.
const { isConnected, error, reconnect, disconnect } = useRealtimeSubscription<Order>({
  table: 'orders',
  event: 'INSERT',  // 'INSERT' | 'UPDATE' | 'DELETE' | '*'
  filter: { column: 'user_id', value: userId },
  onInsert: (order) => toast.success(`New order #${order.id}!`),
  onUpdate: (order, oldOrder) => console.log('Updated:', order),
  onDelete: (oldOrder) => console.log('Deleted:', oldOrder.id),
})

Chat with Convex

With Convex, queries are automatically reactive - no manual subscription setup needed.

Messages Query

// convex/messages.ts
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'

export const list = query({
  args: { channelId: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
      .order('desc')
      .take(100)
  },
})

export const send = mutation({
  args: { channelId: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    return await ctx.db.insert('messages', {
      channelId: args.channelId,
      content: args.content,
      userId: identity.subject,
      createdAt: Date.now(),
    })
  },
})

Using in Components

import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'

function ChatRoom({ channelId }: { channelId: string }) {
  const messages = useQuery(api.messages.list, { channelId })
  const sendMessage = useMutation(api.messages.send)
  const [inputText, setInputText] = useState('')

  // messages automatically updates when new messages arrive!

  const handleSend = async () => {
    if (!inputText.trim()) return
    await sendMessage({ channelId, content: inputText })
    setInputText('')
  }

  if (messages === undefined) return <LoadingSpinner />

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={messages}
        renderItem={({ item }) => (
          <MessageBubble
            content={item.content}
            isOwn={item.userId === currentUserId}
            timestamp={item.createdAt}
          />
        )}
        keyExtractor={(item) => item._id}
        inverted
      />

      <View style={styles.inputRow}>
        <TextInput
          value={inputText}
          onChangeText={setInputText}
          placeholder="Type a message..."
        />
        <Button onPress={handleSend} title="Send" />
      </View>
    </View>
  )
}

Presence with Convex

The boilerplate includes ready-to-use presence functions in convex/realtime.ts:
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'

function OnlineUsers({ channel }: { channel: string }) {
  // Get all users in this channel (auto-updates!)
  const presence = useQuery(api.realtime.getPresence, { channel })

  // Join/leave channel
  const joinPresence = useMutation(api.realtime.joinPresence)
  const leavePresence = useMutation(api.realtime.leavePresence)
  const updatePresence = useMutation(api.realtime.updatePresence)

  useEffect(() => {
    // Join when component mounts
    joinPresence({ channel, state: { status: 'online' } })

    // Leave when component unmounts
    return () => {
      leavePresence({ channel })
    }
  }, [channel])

  return (
    <View>
      <Text>{presence?.count} users online</Text>
      {presence?.users.map(user => (
        <Text key={user.presenceId}>{user.user?.name}</Text>
      ))}
    </View>
  )
}

Presence Functions

FunctionDescription
joinPresence({ channel, state })Join a channel with optional state
updatePresence({ channel, state })Update your presence state (heartbeat)
leavePresence({ channel })Leave a channel
getPresence({ channel })Get all users in a channel (reactive)

Custom Presence State

Store any data with your presence:
// Track cursor position
await updatePresence({
  channel: 'document:123',
  state: {
    cursor: { x: 100, y: 200 },
    status: 'editing',
    color: '#ff0000',
  }
})

Broadcast with Convex

Send ephemeral messages to channel subscribers:
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'

function RealtimeUpdates({ channel }: { channel: string }) {
  // Subscribe to broadcasts (auto-updates!)
  const broadcasts = useQuery(api.realtime.subscribeBroadcast, {
    channel,
    since: Date.now() - 30000, // Last 30 seconds
  })

  // Send a broadcast
  const broadcast = useMutation(api.realtime.broadcast)

  const sendCursorMove = (x: number, y: number) => {
    broadcast({
      channel,
      event: 'cursor_move',
      payload: { x, y },
    })
  }

  return (
    <View>
      {broadcasts?.messages.map(msg => (
        <Text key={msg.id}>
          {msg.event}: {JSON.stringify(msg.payload)}
        </Text>
      ))}
    </View>
  )
}

Broadcast Functions

FunctionDescription
broadcast({ channel, event, payload })Send a message to all subscribers
subscribeBroadcast({ channel, since })Get recent messages (reactive)
Auto-Cleanup: Broadcast messages expire after 30 seconds and are automatically cleaned up by a cron job. This keeps your database lean for ephemeral data like cursor positions.

Database Setup

Messages Table

CREATE TABLE public.messages (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    channel_id TEXT NOT NULL,
    user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
    content TEXT NOT NULL,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ
);

ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can read messages in their channels"
  ON public.messages FOR SELECT TO authenticated USING (true);

CREATE POLICY "Users can insert their own messages"
  ON public.messages FOR INSERT TO authenticated
  WITH CHECK (auth.uid() = user_id);

-- Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE public.messages;

CREATE INDEX idx_messages_channel_id ON public.messages(channel_id);
CREATE INDEX idx_messages_created_at ON public.messages(created_at DESC);

Enable Realtime

In Supabase Dashboard:
  1. Go to DatabaseReplication
  2. Under “Realtime”, enable for your tables
  3. Or run: ALTER PUBLICATION supabase_realtime ADD TABLE your_table;

Mock Mode

All realtime hooks work in mock mode for development without backend credentials:
  • Messages are stored locally and persist during the session
  • Presence shows the current user as online
  • Subscriptions simulate connected state
Testing Tip: Use mock mode to build and test your UI before connecting to a real backend.

TypeScript Types

Import types for your realtime data:
import type {
  RealtimeMessage,
  RealtimeMessageWithUser,
  RealtimeActivity,
  PresenceState,
  TypingState,
} from '@/types/realtime'
See apps/app/app/types/realtime.ts for all available types.