Skip to content

useRealtime

What this solves

Realtime data updates are common in admin dashboards:

  • Live visitor counts
  • Order notifications as they happen
  • Status changes pushed from the server
  • Activity feeds and alerts

Ad-hoc implementations often have:

  • Inconsistent connection management across screens
  • No fallback when WebSocket isn't available
  • Duplicated reconnection/retry logic
  • Complex state synchronization between WS and polling

useRealtimeChannel, useRealtimeMode, and related hooks provide a consistent infrastructure for realtime data with automatic mode switching.


When to use it

Use these hooks when you need:

  • Push-based updates from a WebSocket server
  • Automatic fallback to polling when WebSocket isn't available or desired
  • Connection state awareness (connected, disconnected, reconnecting)
  • Consistent patterns across different realtime features

When NOT to use it

  • For one-time data fetching (use React Query directly)
  • When you don't have a WebSocket endpoint available
  • For simple polling without WebSocket option (use useQuery with refetchInterval)

Core primitives

HookPurpose
useRealtimeChannelSubscribe to a specific channel/topic
useRealtimeModeGlobal mode switching (WebSocket vs Polling)
useRealtimeConnectionLow-level connection management
useRealtimeCounterPre-built hook for counter values with trend
useRealtimeListPre-built hook for lists with new-item highlighting

Basic usage

useRealtimeChannel — Subscribe to a channel

tsx
import { useRealtimeChannel } from '@/shared/infrastructure/realtime'

interface OrderNotification {
  id: string
  amount: number
  timestamp: string
}

export function OrderFeed() {
  const { isConnected, lastMessage } = useRealtimeChannel<OrderNotification>({
    path: '/ws/orders/realtime',
    channel: 'new-order',
  })

  return (
    <div>
      <span>{isConnected ? '🟢 Live' : '🟡 Connecting...'}</span>
      {lastMessage && (
        <p>Latest order: ${lastMessage.data.amount}</p>
      )}
    </div>
  )
}

useRealtimeMode — Switch between modes

tsx
import { useRealtimeMode } from '@/shared/infrastructure/realtime'

export function RealtimeSettings() {
  const { mode, setMode } = useRealtimeMode()

  return (
    <select value={mode} onChange={(e) => setMode(e.target.value as 'websocket' | 'polling')}>
      <option value="websocket">WebSocket (Real-time)</option>
      <option value="polling">Polling (Fallback)</option>
    </select>
  )
}

useRealtimeCounter — Counter with trend

tsx
import { useRealtimeCounter } from '@/shared/infrastructure/realtime'

export function ActiveUsers() {
  const { count, trend, isConnected } = useRealtimeCounter({
    path: '/ws/metrics/realtime',
    channel: 'active-users',
    countField: 'count', // Field in the message containing the count
  })

  return (
    <div>
      <h3>{count ?? '—'} active users</h3>
      <span>{trend === 'up' ? '📈' : trend === 'down' ? '📉' : '➡️'}</span>
    </div>
  )
}

useRealtimeList — List with new-item highlighting

tsx
import { useRealtimeList } from '@/shared/infrastructure/realtime'

interface Notification {
  id: string
  message: string
}

export function NotificationFeed() {
  const { items, newItemIds, isConnected } = useRealtimeList<Notification>({
    path: '/ws/notifications',
    channel: 'notification',
    maxItems: 50,
    extractItem: (msg) => msg.data as Notification,
    getItemId: (n) => n.id,
  })

  return (
    <ul>
      {items.map((item) => (
        <li 
          key={item.id}
          className={newItemIds.has(item.id) ? 'highlight' : ''}
        >
          {item.message}
        </li>
      ))}
    </ul>
  )
}

Building domain-specific hooks

The core primitives are designed to be composed into domain-specific hooks. Here's the recommended pattern:

tsx
import { useRealtimeChannel, useRealtimeMode } from '@/shared/infrastructure/realtime'
import { usePollingHook } from './polling-hooks'

interface UseLiveDataOptions {
  pollInterval?: number
}

export function useLiveData(options: UseLiveDataOptions = {}) {
  const { pollInterval = 5000 } = options
  const { mode } = useRealtimeMode()
  
  // State for domain-specific logic
  const [data, setData] = useState<MyData | null>(null)

  // WebSocket: Use shared primitive
  const { isConnected, lastMessage } = useRealtimeChannel<MyData>({
    path: '/ws/my-app/realtime',
    channel: 'my-channel',
  })

  // Process WebSocket messages
  useEffect(() => {
    if (mode !== 'websocket' || !lastMessage?.data) return
    setData(lastMessage.data)
  }, [mode, lastMessage])

  // Polling: Fallback
  const pollingResult = usePollingHook({
    pollInterval,
    enabled: mode === 'polling',
  })

  useEffect(() => {
    if (mode !== 'polling') return
    setData(pollingResult.data)
  }, [mode, pollingResult.data])

  return {
    data,
    mode,
    isConnected: mode === 'websocket' ? isConnected : true,
  }
}

Server-side message format

The hooks expect messages in this format:

json
{
  "type": "channel-name",
  "channel": "channel-name",
  "data": { /* your payload */ },
  "timestamp": "2024-01-15T10:30:00Z"
}

The channel field is used for routing messages to the correct subscribers.


Mode persistence

useRealtimeMode automatically persists the selected mode to localStorage. Users who prefer polling (or are in environments where WebSocket is blocked) will have their preference remembered.