Appearance
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:
- Use
useRealtimeChannelfor WebSocket subscription - Use
useRealtimeModefor global mode configuration - Provide a polling fallback
- 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:
- Mock
useRealtimeChannelto return controlledlastMessage - Mock
useRealtimeModeto force specific modes - 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)
})Related primitives
- useRealtime — Core realtime hooks
- useRealtime API — Full API reference
- Realtime Components — UI components for realtime data