Shipnative includes ready-to-use hooks for common realtime patterns like chat, presence tracking, and live updates.
Backend Comparison
Feature Supabase Convex Realtime Mechanism PostgreSQL Changes Built-in reactivity Setup Required Enable per table Automatic Subscriptions Manual setup Automatic with useQuery Typing Indicators Via Presence channel Via mutations Presence Via Presence API Via 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 >
)
}
import { useQuery , useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'
function ChatRoom ({ channelId } : { channelId : string }) {
// Messages auto-update in real-time
const messages = useQuery ( api . messages . list , { channelId })
const sendMessage = useMutation ( api . messages . send )
return (
< View >
{ messages ?. map ( msg => < Text key ={ msg . _id }>{msg. content } </ Text > )}
< Button
onPress = {() => sendMessage ({ channelId , content : '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
Function Description 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
Function Description 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:
Go to Database → Replication
Under “Realtime”, enable for your tables
Or run: ALTER PUBLICATION supabase_realtime ADD TABLE your_table;
The boilerplate includes presence and broadcast tables in convex/schema.ts: // Presence tracking
presence : defineTable ({
channel: v . string (),
userId: v . id ( "users" ),
state: v . optional ( v . any ()),
joinedAt: v . number (),
lastSeenAt: v . number (),
})
. index ( "by_channel" , [ "channel" ])
. index ( "by_channel_user" , [ "channel" , "userId" ])
. index ( "by_lastSeenAt" , [ "lastSeenAt" ]),
// Broadcast messages
broadcasts : defineTable ({
channel: v . string (),
event: v . string (),
payload: v . any (),
senderId: v . optional ( v . id ( "users" )),
createdAt: v . number (),
expiresAt: v . number (),
})
. index ( "by_channel" , [ "channel" ])
. index ( "by_channel_created" , [ "channel" , "createdAt" ])
. index ( "by_expiresAt" , [ "expiresAt" ]),
Automatic Cleanup Cron jobs in convex/crons.ts handle cleanup:
Presence cleanup : Every minute, removes stale presence older than 60 seconds
Broadcast cleanup : Every 5 minutes, removes expired broadcast messages
Convex handles realtime automatically - no additional setup needed!
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.