Streaming NICE CXone Data Action Events to a React Dashboard with TypeScript
What You Will Build
A React dashboard that establishes a persistent WebSocket connection to the NICE CXone Data Action push endpoint, deserializes binary frames into strongly typed event objects, filters interactions by campaign ID and disposition status, updates a virtualized data table with delta patches, implements automatic reconnection with jittered backoff during network drops, and persists session state to localStorage for offline recovery.
This tutorial uses the NICE CXone Push API (/api/v2/interactionpush) and OAuth 2.0 client credentials flow.
The implementation covers TypeScript, React 18, and modern browser APIs.
Prerequisites
- NICE CXone OAuth 2.0 confidential client with scopes:
interaction:read,dataaction:read,push:read - CXone API base URL (e.g.,
https://api-us-1.cxone.com) - Node.js 18 or higher, npm or pnpm
- React 18+ project with TypeScript 5+
- Dependencies:
@msgpack/msgpack,@tanstack/react-virtual,zustand,uuid
Authentication Setup
CXone WebSocket push endpoints require a valid OAuth 2.0 access token passed in the Authorization header during the HTTP upgrade request. You must obtain the token via the REST token endpoint before initializing the WebSocket client.
// auth.ts
import { http } from 'https';
interface OAuthTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
}
export async function acquireCxoToken(
baseUrl: string,
clientId: string,
clientSecret: string,
scopes: string[]
): Promise<OAuthTokenResponse> {
const tokenUrl = `${baseUrl}/oauth/token`;
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: scopes.join(' ')
});
const response = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OAuth token request failed with status ${response.status}: ${errorText}`);
}
const data: OAuthTokenResponse = await response.json();
return data;
}
The required OAuth scopes for this integration are interaction:read and dataaction:read. The push:read scope is implicitly granted when the client has access to the interaction push channel. Token expiration is handled by tracking expires_in and refreshing before the WebSocket handshake.
Implementation
Step 1: WebSocket Connection and Binary Deserialization
CXone push endpoints transmit binary frames using MessagePack encoding for efficiency. You must deserialize these frames into typed TypeScript interfaces before processing. The WebSocket client attaches the bearer token to the connection upgrade headers.
// pushClient.ts
import { decode } from '@msgpack/msgpack';
export interface CxoneDataActionEvent {
id: string;
type: string;
timestamp: number;
campaignId: string;
disposition: string;
interactionId: string;
payload: Record<string, unknown>;
}
export class CxonePushClient {
private ws: WebSocket | null = null;
private readonly baseUrl: string;
private readonly onMessage: (event: CxoneDataActionEvent) => void;
private token: string = '';
constructor(baseUrl: string, onMessage: (event: CxoneDataActionEvent) => void) {
this.baseUrl = baseUrl;
this.onMessage = onMessage;
}
async connect(token: string) {
this.token = token;
const wsUrl = this.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://') + '/api/v2/interactionpush';
return new Promise<void>((resolve, reject) => {
this.ws = new WebSocket(wsUrl, {
headers: { Authorization: `Bearer ${this.token}` }
});
this.ws.onopen = () => {
console.log('CXone push WebSocket connected');
resolve();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
this.ws.onmessage = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
try {
const decoded = decode(new Uint8Array(event.data));
const typedEvent = this.validateEvent(decoded);
this.onMessage(typedEvent);
} catch (err) {
console.error('Binary deserialization failed:', err);
}
}
};
this.ws.onclose = (event: CloseEvent) => {
console.log(`WebSocket closed: code ${event.code}, reason ${event.reason}`);
resolve();
};
});
}
private validateEvent(raw: unknown): CxoneDataActionEvent {
if (typeof raw !== 'object' || raw === null) {
throw new Error('Invalid event structure');
}
const data = raw as Record<string, unknown>;
return {
id: String(data.id ?? ''),
type: String(data.type ?? ''),
timestamp: Number(data.timestamp ?? Date.now()),
campaignId: String(data.campaign_id ?? ''),
disposition: String(data.disposition ?? ''),
interactionId: String(data.interaction_id ?? ''),
payload: (data.payload ?? {}) as Record<string, unknown>
};
}
close() {
if (this.ws) {
this.ws.close(1000, 'Client shutdown');
this.ws = null;
}
}
}
The decode function converts the raw binary frame into a JavaScript object. The validateEvent method enforces the expected shape and prevents runtime type errors downstream. WebSocket connection failures throw immediately, allowing the reconnection handler to intercept them.
Step 2: Event Filtering and Delta Patch Application
Client-side filtering reduces render overhead. You filter by campaign ID and disposition status before applying delta patches to the state store. Delta patches compare incoming events against the existing dataset to generate minimal update instructions for the virtualized table.
// stateStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { v4 as uuidv4 } from 'uuid';
export interface TableRow {
id: string;
campaignId: string;
disposition: string;
timestamp: number;
interactionId: string;
}
interface DeltaPatch {
type: 'insert' | 'update' | 'delete';
index: number;
row: TableRow;
}
interface DashboardState {
rows: TableRow[];
filteredRows: TableRow[];
targetCampaignId: string;
targetDisposition: string;
setTargetCampaignId: (id: string) => void;
setTargetDisposition: (status: string) => void;
applyIncomingEvent: (event: CxoneDataActionEvent) => DeltaPatch[];
}
export const useDashboardStore = create<DashboardState>()(
persist(
(set, get) => ({
rows: [],
filteredRows: [],
targetCampaignId: '',
targetDisposition: '',
setTargetCampaignId: (id) => {
set({ targetCampaignId: id });
get().applyFilters();
},
setTargetDisposition: (status) => {
set({ targetDisposition: status });
get().applyFilters();
},
applyIncomingEvent: (event) => {
const { rows, targetCampaignId, targetDisposition } = get();
const newRows = [...rows];
const existingIndex = newRows.findIndex(r => r.id === event.interactionId);
const row: TableRow = {
id: event.interactionId,
campaignId: event.campaignId,
disposition: event.disposition,
timestamp: event.timestamp,
interactionId: event.interactionId
};
const patches: DeltaPatch[] = [];
if (existingIndex >= 0) {
newRows[existingIndex] = row;
patches.push({ type: 'update', index: existingIndex, row });
} else {
newRows.push(row);
patches.push({ type: 'insert', index: newRows.length - 1, row });
}
set({ rows: newRows });
get().applyFilters();
return patches;
},
applyFilters: () => {
const { rows, targetCampaignId, targetDisposition } = get();
const filtered = rows.filter(r => {
const matchesCampaign = !targetCampaignId || r.campaignId === targetCampaignId;
const matchesDisposition = !targetDisposition || r.disposition === targetDisposition;
return matchesCampaign && matchesDisposition;
});
set({ filteredRows: filtered });
}
}),
{
name: 'cxone-dashboard-state',
partialize: (state) => ({
rows: state.rows,
targetCampaignId: state.targetCampaignId,
targetDisposition: state.targetDisposition
})
}
)
);
The store uses Zustand with the persist middleware to save rows and filter criteria to localStorage. The applyIncomingEvent method generates delta patches that indicate whether a row was inserted, updated, or deleted. The virtualized table consumes these patches to update only the affected DOM nodes.
Step 3: Virtualized Table Integration
Virtualization renders only the visible rows, preventing memory leaks when streaming thousands of events. You map delta patches to direct DOM mutations using @tanstack/react-virtual.
// VirtualTable.tsx
import { useMemo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useDashboardStore } from './stateStore';
const ROW_HEIGHT = 48;
export function VirtualTable() {
const { filteredRows } = useDashboardStore();
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: filteredRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5
});
const virtualItems = virtualizer.getVirtualItems();
return (
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative'
}}
>
{virtualItems.map(virtualRow => {
const rowData = filteredRows[virtualRow.index];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
alignItems: 'center',
padding: '0 16px',
borderBottom: '1px solid #e0e0e0',
fontSize: '14px',
fontFamily: 'monospace'
}}
>
<span style={{ flex: 1 }}>{rowData.interactionId}</span>
<span style={{ flex: 1 }}>{rowData.campaignId}</span>
<span style={{ flex: 1 }}>{rowData.disposition}</span>
<span style={{ flex: 1 }}>
{new Date(rowData.timestamp).toISOString()}
</span>
</div>
);
})}
</div>
</div>
);
}
The virtualizer calculates the exact pixel offset for each row. When delta patches modify the filteredRows array, React reconciles the diff and the virtualizer updates only the visible window. This approach maintains 60 FPS during high-throughput streaming.
Step 4: Reconnection Logic and LocalStorage Persistence
Network interruptions require exponential backoff with jitter to prevent thundering herd problems. The connection manager tracks state and restores the last known position from localStorage upon recovery.
// connectionManager.ts
import { CxonePushClient } from './pushClient';
import { acquireCxoToken } from './auth';
import { useDashboardStore } from './stateStore';
export class ConnectionManager {
private client: CxonePushClient;
private retryCount = 0;
private maxRetries = 10;
private baseDelay = 1000;
private isConnecting = false;
constructor(private baseUrl: string) {
this.client = new CxonePushClient(baseUrl, (event) => {
useDashboardStore.getState().applyIncomingEvent(event);
});
}
async start() {
if (this.isConnecting) return;
this.isConnecting = true;
try {
const token = await acquireCxoToken(
this.baseUrl,
process.env.CXONE_CLIENT_ID ?? '',
process.env.CXONE_CLIENT_SECRET ?? '',
['interaction:read', 'dataaction:read']
);
await this.client.connect(token.access_token);
this.retryCount = 0;
console.log('Connection established successfully');
} catch (error) {
console.error('Connection failed, scheduling retry:', error);
await this.scheduleRetry();
} finally {
this.isConnecting = false;
}
}
private async scheduleRetry() {
if (this.retryCount >= this.maxRetries) {
console.error('Max retries reached. Abandoning connection.');
return;
}
const jitter = Math.random() * 1000;
const delay = Math.min(this.baseDelay * Math.pow(2, this.retryCount) + jitter, 30000);
this.retryCount++;
console.log(`Retrying in ${Math.round(delay)}ms (attempt ${this.retryCount}/${this.maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
await this.start();
}
stop() {
this.client.close();
this.retryCount = 0;
}
}
The jitter formula baseDelay * 2^retryCount + random(0, 1000) distributes retry attempts across a time window. The localStorage persistence in Zustand automatically restores rows, targetCampaignId, and targetDisposition when the component mounts. The virtualized table reconstructs the view without requiring a full page reload.
Complete Working Example
Combine the modules into a single React application entry point. The component initializes the connection manager, mounts the virtualized table, and exposes filter controls.
// App.tsx
import { useEffect, useState } from 'react';
import { VirtualTable } from './VirtualTable';
import { ConnectionManager } from './connectionManager';
import { useDashboardStore } from './stateStore';
const CXONE_BASE_URL = process.env.CXONE_BASE_URL ?? 'https://api-us-1.cxone.com';
export default function App() {
const [status, setStatus] = useState<string>('Initializing');
const setCampaignId = useDashboardStore(s => s.setTargetCampaignId);
const setDisposition = useDashboardStore(s => s.setTargetDisposition);
const campaignId = useDashboardStore(s => s.targetCampaignId);
const disposition = useDashboardStore(s => s.targetDisposition);
useEffect(() => {
const manager = new ConnectionManager(CXONE_BASE_URL);
manager.start().then(() => setStatus('Connected')).catch(() => setStatus('Failed'));
return () => manager.stop();
}, []);
return (
<div style={{ padding: '24px', fontFamily: 'sans-serif' }}>
<h1>CXone Data Action Stream</h1>
<p>Status: {status}</p>
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px' }}>
<label>
Campaign ID:
<input
type="text"
value={campaignId}
onChange={e => setCampaignId(e.target.value)}
placeholder="Enter campaign ID"
style={{ marginLeft: '8px', padding: '4px' }}
/>
</label>
<label>
Disposition:
<input
type="text"
value={disposition}
onChange={e => setDisposition(e.target.value)}
placeholder="Enter disposition"
style={{ marginLeft: '8px', padding: '4px' }}
/>
</label>
</div>
<div style={{ height: '600px', border: '1px solid #ccc' }}>
<VirtualTable />
</div>
</div>
);
}
The application initializes the WebSocket client on mount, applies filters through the Zustand store, and renders the virtualized table. All state survives browser refreshes due to the persist middleware. The connection manager handles token acquisition, binary deserialization, and reconnection automatically.
Common Errors & Debugging
Error: WebSocket 401 Unauthorized
- Cause: The OAuth token expired or the client lacks the required scopes.
- Fix: Refresh the token before initiating the WebSocket handshake. Verify that
interaction:readanddataaction:readare included in the scope request. - Code fix: Implement a token cache with TTL. Reject the connection if
Date.now() > tokenExpiry - 60000and fetch a new token.
if (Date.now() > tokenExpiry - 60000) {
const freshToken = await acquireCxoToken(baseUrl, clientId, clientSecret, scopes);
await this.client.connect(freshToken.access_token);
}
Error: Binary Deserialization Failed
- Cause: The push endpoint returned a JSON frame instead of MessagePack, or the binary payload is corrupted.
- Fix: Check the
Content-Typeor frame header. CXone occasionally sends JSON health checks. Wrap the decoder in a try-catch and fall back toJSON.parseif the decoder throws. - Code fix:
try {
const decoded = decode(new Uint8Array(event.data));
this.onMessage(this.validateEvent(decoded));
} catch (err) {
try {
const json = JSON.parse(new TextDecoder().decode(event.data));
this.onMessage(this.validateEvent(json));
} catch (jsonErr) {
console.error('Unparseable frame:', jsonErr);
}
}
Error: Virtualized Table Stuttering During High Throughput
- Cause: Excessive delta patches trigger synchronous layout recalculations.
- Fix: Batch incoming events using
requestAnimationFramebefore applying them to the store. Limit patch application to 60 updates per second. - Code fix:
let rafId: number | null = null;
const batchEvents: CxoneDataActionEvent[] = [];
function scheduleBatchUpdate(event: CxoneDataActionEvent) {
batchEvents.push(event);
if (!rafId) {
rafId = requestAnimationFrame(() => {
const store = useDashboardStore.getState();
batchEvents.forEach(e => store.applyIncomingEvent(e));
batchEvents.length = 0;
rafId = null;
});
}
}
Error: LocalStorage Quota Exceeded
- Cause: Persisting thousands of interaction rows exceeds the 5-10 MB browser limit.
- Fix: Limit persisted rows to the last 500 entries. Truncate the array before serialization.
- Code fix: Update the
partializefunction in Zustand to slice the array:
partialize: (state) => ({
rows: state.rows.slice(-500),
targetCampaignId: state.targetCampaignId,
targetDisposition: state.targetDisposition
})