Live Presence
Track who is online in real time. Users announce their presence when they join, update it as they interact, and Aerostack automatically cleans up when they disconnect.
Demonstrates: channel.track(), presence:join, presence:update, presence:leave, auto-cleanup on disconnect
Source: examples/socket-hub/src/examples/LivePresence/
No server code required for this example — presence is entirely handled by the Aerostack WebSocket layer.
Client code
import { useAerostack } from '@aerostack/react'
import { useEffect, useRef, useState } from 'react'
interface PeerState {
userId: string
name: string
color: string
status: 'online' | 'away'
joinedAt: number
}
interface PresenceEvent {
type: 'join' | 'update' | 'leave'
userId: string
name: string
timestamp: number
}
const COLORS = ['#FF5733', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6', '#E74C3C', '#1ABC9C']
const NAMES = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace']
function randomFrom<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}
export function LivePresence() {
const { realtime } = useAerostack()
const [peers, setPeers] = useState<PeerState[]>([])
const [events, setEvents] = useState<PresenceEvent[]>([])
const myId = useRef(`user-${Math.random().toString(36).slice(2, 8)}`)
const myName = useRef(randomFrom(NAMES))
const myColor = useRef(randomFrom(COLORS))
const addEvent = (type: PresenceEvent['type'], userId: string, name: string) => {
setEvents(prev => [
{ type, userId, name, timestamp: Date.now() },
...prev.slice(0, 19), // keep last 20 events
])
}
useEffect(() => {
const channel = realtime.channel('presence/demo-room')
channel
.on('presence:join', ({ data }) => {
setPeers(prev => {
if (prev.find(p => p.userId === data.userId)) return prev
return [...prev, data]
})
addEvent('join', data.userId, data.name)
})
.on('presence:update', ({ data }) => {
setPeers(prev => prev.map(p =>
p.userId === data.userId ? { ...p, ...data } : p
))
})
.on('presence:leave', ({ data }) => {
setPeers(prev => prev.filter(p => p.userId !== data.userId))
addEvent('leave', data.userId, data.name)
})
.subscribe()
// Announce our presence
channel.track({
userId: myId.current,
name: myName.current,
color: myColor.current,
status: 'online',
joinedAt: Date.now(),
})
// Go "away" after 30s of inactivity
let awayTimer: ReturnType<typeof setTimeout>
const resetAwayTimer = () => {
clearTimeout(awayTimer)
channel.track({
userId: myId.current,
name: myName.current,
color: myColor.current,
status: 'online',
joinedAt: Date.now(),
})
awayTimer = setTimeout(() => {
channel.track({
userId: myId.current,
name: myName.current,
color: myColor.current,
status: 'away',
joinedAt: Date.now(),
})
}, 30_000)
}
document.addEventListener('mousemove', resetAwayTimer)
document.addEventListener('keydown', resetAwayTimer)
return () => {
clearTimeout(awayTimer)
document.removeEventListener('mousemove', resetAwayTimer)
document.removeEventListener('keydown', resetAwayTimer)
channel.untrack()
channel.unsubscribe()
}
}, [realtime])
return (
<div className="max-w-2xl mx-auto p-6 space-y-8">
<div>
<h2 className="text-xl font-bold text-white mb-4">
Online ({peers.length})
</h2>
<div className="flex gap-3 flex-wrap">
{peers.map(peer => (
<div
key={peer.userId}
className="flex items-center gap-2 px-4 py-2 rounded-full text-white text-sm font-medium"
style={{ background: peer.color + '33', border: `1px solid ${peer.color}66` }}
>
<span
className={`w-2 h-2 rounded-full ${peer.status === 'online' ? 'bg-green-400' : 'bg-yellow-400'}`}
/>
<span style={{ color: peer.color }}>{peer.name}</span>
{peer.userId === myId.current && (
<span className="text-xs opacity-60">(you)</span>
)}
</div>
))}
</div>
</div>
<div>
<h2 className="text-xl font-bold text-white mb-4">Event log</h2>
<div className="space-y-1">
{events.map((event, i) => (
<div key={i} className="text-sm text-gray-400">
<span className="text-gray-600 mr-2">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<span className={
event.type === 'join' ? 'text-green-400' :
event.type === 'leave' ? 'text-red-400' :
'text-yellow-400'
}>
{event.name} {event.type === 'join' ? 'joined' : event.type === 'leave' ? 'left' : 'updated'}
</span>
</div>
))}
</div>
</div>
</div>
)
}How auto-cleanup works
- User’s browser connects via WebSocket and calls
channel.track(state) - Aerostack stores the presence state in memory
- When the connection closes (tab closes, network loss, etc.), Aerostack starts a 90-second grace period
- If the user reconnects within 90s, their presence is restored
- After 90s, Aerostack emits
presence:leaveto all subscribers and removes the state