Skip to content

Realtime Data

Problem

Realtime data in admin dashboards has multiple challenges:

  • Infrastructure varies — Some environments support WebSocket, others don't
  • Connection management — Handling connect, disconnect, reconnect states
  • Mode switching — Users may prefer polling over WebSocket
  • Consistent API — Components shouldn't care about the data transport

Common failure modes:

  • Direct WebSocket usage in components, making them hard to test
  • No fallback when WebSocket isn't available
  • Duplicated reconnection logic across features
  • Connection state scattered across components

Solution

Use Katalyst's realtime primitives to build domain-specific hooks that:

  1. Use useRealtimeChannel for WebSocket subscription
  2. Use useRealtimeMode for global mode configuration
  3. Provide a polling fallback
  4. Expose a unified API regardless of transport

This keeps domain-specific logic in your feature module while leveraging shared infrastructure.

Implementation

Domain-specific realtime hook with fallback

tsx
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRealtimeChannel, useRealtimeMode } from '@/shared/infrastructure/realtime'
import { usePollingQuery } from './use-polling-query' // Your polling hook

// Domain-specific message type
interface LiveMetric {
  value: number
  timestamp: number
}

interface UseLiveMetricOptions {
  /** Polling interval in ms (default: 5 seconds) */
  pollInterval?: number
}

/**
 * Domain-specific hook for live metrics
 * 
 * Uses shared primitives:
 * - useRealtimeChannel for WebSocket
 * - useRealtimeMode for mode switching
 * 
 * Domain-specific logic stays here:
 * - Value processing
 * - Trend calculation
 * - Pause/resume
 */
export function useLiveMetric(options: UseLiveMetricOptions = {}) {
  const { pollInterval = 5000 } = options
  const { mode } = useRealtimeMode()
  
  // ─────────────────────────────────────────────────────────────────────────
  // State
  // ─────────────────────────────────────────────────────────────────────────
  const [value, setValue] = useState<number | null>(null)
  const [isPaused, setIsPaused] = useState(false)
  const isPausedRef = useRef(isPaused)
  
  useEffect(() => {
    isPausedRef.current = isPaused
  }, [isPaused])

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

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

  // ─────────────────────────────────────────────────────────────────────────
  // Polling: Fallback when WebSocket is not available
  // ─────────────────────────────────────────────────────────────────────────
  const pollingResult = usePollingQuery({
    pollInterval,
    enabled: mode === 'polling' && !isPaused,
  })

  useEffect(() => {
    if (mode !== 'polling' || isPausedRef.current) return
    setValue(pollingResult.value)
  }, [mode, pollingResult.value])

  // ─────────────────────────────────────────────────────────────────────────
  // Controls
  // ─────────────────────────────────────────────────────────────────────────
  const togglePause = useCallback(() => setIsPaused(prev => !prev), [])
  
  const refetch = useCallback(() => {
    if (mode === 'polling') {
      pollingResult.refetch()
    }
  }, [mode, pollingResult])

  // ─────────────────────────────────────────────────────────────────────────
  // Unified API
  // ─────────────────────────────────────────────────────────────────────────
  return {
    // Data
    value,
    
    // Controls
    isPaused,
    togglePause,
    refetch,
    
    // Connection state
    mode,
    isConnected: mode === 'websocket' ? isConnected : true,
    isLoading: mode === 'polling' ? pollingResult.isLoading : false,
  }
}

Using the domain hook in a component

tsx
import { useLiveMetric } from './hooks/use-live-metric'
import { ConnectionStatus } from '@/shared/ui/components/realtime'

export function LiveMetricWidget() {
  const { value, isPaused, togglePause, mode, isConnected } = useLiveMetric()

  return (
    <div>
      <div className="flex items-center gap-2">
        <h3>{value ?? '—'}</h3>
        <ConnectionStatus isConnected={isConnected} mode={mode} />
      </div>
      
      <button onClick={togglePause}>
        {isPaused ? 'Resume' : 'Pause'}
      </button>
      
      {isPaused && <span>Updates paused</span>}
    </div>
  )
}

Key principles

1. Shared primitives stay generic

The infrastructure hooks (useRealtimeChannel, useRealtimeMode) don't contain domain logic. They handle:

  • Connection management
  • Reconnection
  • Channel subscription
  • Mode persistence

2. Domain logic lives in feature hooks

Your domain hooks handle:

  • Data processing and transformation
  • Buffer management (rolling windows, lists)
  • Derived values (trends, aggregations)
  • Feature-specific controls (pause/resume)

3. Unified API hides transport

Components don't know (or care) whether data comes from WebSocket or polling:

tsx
// Same API regardless of transport
const { value, isConnected } = useLiveMetric()

Variations

List with new-item highlighting

tsx
export function useRealtimeNotifications(options = {}) {
  const [notifications, setNotifications] = useState([])
  const [newIds, setNewIds] = useState(new Set())

  const { lastMessage } = useRealtimeChannel({ ... })

  useEffect(() => {
    if (!lastMessage?.data) return
    
    const newItem = lastMessage.data
    setNotifications(prev => [newItem, ...prev].slice(0, 50))
    
    // Highlight briefly
    setNewIds(new Set([newItem.id]))
    setTimeout(() => setNewIds(new Set()), 2000)
  }, [lastMessage])

  return { notifications, newIds }
}

Rolling buffer for charts

tsx
export function useLiveChartData(options = {}) {
  const { windowMs = 60000 } = options
  const [buffer, setBuffer] = useState([])

  const { lastMessage } = useRealtimeChannel({ ... })

  useEffect(() => {
    if (!lastMessage?.data) return
    
    const now = Date.now()
    setBuffer(prev => {
      const point = { t: now, value: lastMessage.data.value }
      return [...prev, point].filter(p => now - p.t < windowMs)
    })
  }, [lastMessage, windowMs])

  return { chartData: buffer }
}

Testing

Domain hooks are easier to test because:

  1. Mock useRealtimeChannel to return controlled lastMessage
  2. Mock useRealtimeMode to force specific modes
  3. Test domain logic (processing, buffering, trends) independently
tsx
// Mock the primitives
vi.mock('@/shared/infrastructure/realtime', () => ({
  useRealtimeChannel: () => ({
    isConnected: true,
    lastMessage: { data: { value: 42 } },
  }),
  useRealtimeMode: () => ({ mode: 'websocket' }),
}))

// Test your domain hook
test('processes WebSocket messages', () => {
  const { result } = renderHook(() => useLiveMetric())
  expect(result.current.value).toBe(42)
})