React Examples

Complete, copy-paste React components for chat and voice. Built for Vite + TypeScript but works with any React setup.

Setup

Make sure you've installed the SDK and set up environment variables. See the Getting Started guide.

Environment Variables

.env
VITE_CHAT_BASE_URL=https://app.yourserver.com/api/v1
VITE_CHAT_API_KEY=app-xxxxxxxxxxxx
VITE_VOICE_BASE_URL=https://api.xpectrum.co/
VITE_VOICE_API_KEY=xpectrum_ai_sk_xxxxxxxxxxxx
VITE_VOICE_AGENT_NAME=your-agent-uuid-here

1. Custom Hook: useXpectrumChat

This reusable hook encapsulates all chat logic. Use it in any component.

src/hooks/useXpectrumChat.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import { XpectrumChat } from '@xpectrum/sdk';

interface Message {
  id: string;
  role: 'user' | 'assistant';
  text: string;
}

export function useXpectrumChat() {
  const chatRef = useRef<XpectrumChat | null>(null);
  const [messages, setMessages] = useState<Message[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string>();
  const [conversationId, setConversationId] = useState<string>();

  // Initialize once
  useEffect(() => {
    chatRef.current = new XpectrumChat({
      baseUrl: import.meta.env.VITE_CHAT_BASE_URL,
      apiKey: import.meta.env.VITE_CHAT_API_KEY,
      user: 'demo-user',
    });
    return () => chatRef.current?.destroy();
  }, []);

  // Send a message
  const sendMessage = useCallback(async (text: string) => {
    if (!text.trim() || loading || !chatRef.current) return;

    setError(undefined);
    setLoading(true);

    // Add user message + empty assistant placeholder
    const userMsg: Message = { id: `user-${Date.now()}`, role: 'user', text };
    const assistantMsg: Message = { id: `asst-${Date.now()}`, role: 'assistant', text: '' };
    setMessages(prev => [...prev, userMsg, assistantMsg]);

    await chatRef.current.sendMessage(text, {
      conversationId,

      onMessage: (responseText, messageId, convId) => {
        setConversationId(convId);
        setMessages(prev => {
          const updated = [...prev];
          updated[updated.length - 1] = {
            id: messageId,
            role: 'assistant',
            text: responseText,
          };
          return updated;
        });
      },

      onError: (err) => {
        setError(err.message);
        setLoading(false);
      },

      onCompleted: () => {
        setLoading(false);
      },
    });
  }, [conversationId, loading]);

  // Start a new conversation
  const newConversation = useCallback(() => {
    setMessages([]);
    setConversationId(undefined);
    setError(undefined);
  }, []);

  return { messages, loading, error, conversationId, sendMessage, newConversation };
}

2. Simple Chat Component

The most basic chat UI using the hook above. Copy this into your project and it works.

src/components/SimpleChat.tsx
import { useState } from 'react';
import { useXpectrumChat } from '../hooks/useXpectrumChat';

export default function SimpleChat() {
  const { messages, loading, error, sendMessage, newConversation } = useXpectrumChat();
  const [input, setInput] = useState('');

  const handleSend = () => {
    if (!input.trim()) return;
    sendMessage(input);
    setInput('');
  };

  return (
    <div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
        <h2 style={{ margin: 0 }}>Chat</h2>
        <button onClick={newConversation}>New Chat</button>
      </div>

      {/* Messages */}
      <div style={{
        border: '1px solid #e5e7eb',
        borderRadius: 12,
        padding: 16,
        minHeight: 300,
        maxHeight: 500,
        overflowY: 'auto',
        marginBottom: 16,
      }}>
        {messages.length === 0 && (
          <p style={{ color: '#9ca3af', textAlign: 'center', marginTop: 80 }}>
            Send a message to start chatting
          </p>
        )}

        {messages.map((msg) => (
          <div
            key={msg.id}
            style={{
              display: 'flex',
              justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
              marginBottom: 8,
            }}
          >
            <div
              style={{
                maxWidth: '80%',
                padding: '10px 14px',
                borderRadius: 12,
                background: msg.role === 'user' ? '#7c3aed' : '#f3f4f6',
                color: msg.role === 'user' ? '#fff' : '#111',
                fontSize: 14,
                lineHeight: 1.5,
                whiteSpace: 'pre-wrap',
              }}
            >
              {msg.text || '...'}
            </div>
          </div>
        ))}

        {loading && (
          <p style={{ color: '#9ca3af', fontSize: 14 }}>AI is typing...</p>
        )}
      </div>

      {/* Error */}
      {error && (
        <p style={{ color: '#ef4444', fontSize: 14, marginBottom: 8 }}>
          Error: {error}
        </p>
      )}

      {/* Input */}
      <div style={{ display: 'flex', gap: 8 }}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
          placeholder="Type a message..."
          disabled={loading}
          style={{
            flex: 1,
            padding: '10px 14px',
            borderRadius: 8,
            border: '1px solid #e5e7eb',
            fontSize: 14,
            outline: 'none',
          }}
        />
        <button
          onClick={handleSend}
          disabled={loading || !input.trim()}
          style={{
            padding: '10px 20px',
            borderRadius: 8,
            background: '#7c3aed',
            color: '#fff',
            border: 'none',
            cursor: 'pointer',
            fontSize: 14,
            opacity: loading || !input.trim() ? 0.5 : 1,
          }}
        >
          Send
        </button>
      </div>
    </div>
  );
}

3. Chat with Conversation History

A more complete example that shows a sidebar with past conversations and lets you switch between them.

src/components/ChatWithHistory.tsx
import { useEffect, useRef, useState, useCallback } from 'react';
import { XpectrumChat } from '@xpectrum/sdk';

interface Message {
  id: string;
  role: 'user' | 'assistant';
  text: string;
}

interface Conversation {
  id: string;
  name: string;
  created_at: number;
}

export default function ChatWithHistory() {
  const chatRef = useRef<XpectrumChat | null>(null);
  const [conversations, setConversations] = useState<Conversation[]>([]);
  const [activeConvId, setActiveConvId] = useState<string>();
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);

  // Initialize
  useEffect(() => {
    chatRef.current = new XpectrumChat({
      baseUrl: import.meta.env.VITE_CHAT_BASE_URL,
      apiKey: import.meta.env.VITE_CHAT_API_KEY,
      user: 'demo-user',
    });
    loadConversations();
    return () => chatRef.current?.destroy();
  }, []);

  // Load conversation list
  const loadConversations = async () => {
    if (!chatRef.current) return;
    try {
      const { data } = await chatRef.current.getConversations({ limit: 50 });
      setConversations(data);
    } catch (err) {
      console.error('Failed to load conversations:', err);
    }
  };

  // Load messages for a conversation
  const loadConversation = async (convId: string) => {
    if (!chatRef.current) return;
    setActiveConvId(convId);
    const { data } = await chatRef.current.getMessages(convId, { limit: 50 });
    // Convert server messages to our format
    const msgs: Message[] = [];
    for (const msg of data) {
      msgs.push({ id: `q-${msg.id}`, role: 'user', text: msg.query });
      msgs.push({ id: msg.id, role: 'assistant', text: msg.answer });
    }
    setMessages(msgs);
  };

  // Send message
  const handleSend = useCallback(async () => {
    if (!input.trim() || loading || !chatRef.current) return;
    const query = input;
    setInput('');
    setLoading(true);

    const userMsg: Message = { id: `u-${Date.now()}`, role: 'user', text: query };
    const assistantMsg: Message = { id: `a-${Date.now()}`, role: 'assistant', text: '' };
    setMessages(prev => [...prev, userMsg, assistantMsg]);

    await chatRef.current.sendMessage(query, {
      conversationId: activeConvId,
      onMessage: (text, messageId, convId) => {
        if (!activeConvId) setActiveConvId(convId);
        setMessages(prev => {
          const updated = [...prev];
          updated[updated.length - 1] = { id: messageId, role: 'assistant', text };
          return updated;
        });
      },
      onCompleted: () => {
        setLoading(false);
        loadConversations(); // Refresh sidebar
      },
      onError: () => setLoading(false),
    });
  }, [input, loading, activeConvId]);

  // New conversation
  const newChat = () => {
    setActiveConvId(undefined);
    setMessages([]);
  };

  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      {/* Sidebar */}
      <div style={{
        width: 260,
        borderRight: '1px solid #e5e7eb',
        padding: 16,
        overflowY: 'auto',
      }}>
        <button
          onClick={newChat}
          style={{
            width: '100%',
            padding: '10px 14px',
            borderRadius: 8,
            background: '#7c3aed',
            color: '#fff',
            border: 'none',
            cursor: 'pointer',
            marginBottom: 16,
          }}
        >
          + New Chat
        </button>

        {conversations.map((conv) => (
          <div
            key={conv.id}
            onClick={() => loadConversation(conv.id)}
            style={{
              padding: '10px 12px',
              borderRadius: 8,
              marginBottom: 4,
              cursor: 'pointer',
              background: activeConvId === conv.id ? '#ede9fe' : 'transparent',
              fontSize: 14,
              overflow: 'hidden',
              textOverflow: 'ellipsis',
              whiteSpace: 'nowrap',
            }}
          >
            {conv.name || 'Untitled'}
          </div>
        ))}
      </div>

      {/* Chat area */}
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', padding: 20 }}>
        <div style={{ flex: 1, overflowY: 'auto', marginBottom: 16 }}>
          {messages.map((msg) => (
            <div
              key={msg.id}
              style={{
                display: 'flex',
                justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
                marginBottom: 8,
              }}
            >
              <div
                style={{
                  maxWidth: '70%',
                  padding: '10px 14px',
                  borderRadius: 12,
                  background: msg.role === 'user' ? '#7c3aed' : '#f3f4f6',
                  color: msg.role === 'user' ? '#fff' : '#111',
                  fontSize: 14,
                  whiteSpace: 'pre-wrap',
                }}
              >
                {msg.text || '...'}
              </div>
            </div>
          ))}
        </div>

        <div style={{ display: 'flex', gap: 8 }}>
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === 'Enter' && handleSend()}
            placeholder="Type a message..."
            disabled={loading}
            style={{
              flex: 1, padding: '10px 14px', borderRadius: 8,
              border: '1px solid #e5e7eb', fontSize: 14,
            }}
          />
          <button
            onClick={handleSend}
            disabled={loading || !input.trim()}
            style={{
              padding: '10px 20px', borderRadius: 8, background: '#7c3aed',
              color: '#fff', border: 'none', cursor: 'pointer',
            }}
          >
            Send
          </button>
        </div>
      </div>
    </div>
  );
}

4. Custom Hook: useXpectrumVoice

src/hooks/useXpectrumVoice.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import { XpectrumVoice } from '@xpectrum/sdk';
import type { TranscriptionSegment } from '@xpectrum/sdk';

export function useXpectrumVoice() {
  const voiceRef = useRef<XpectrumVoice | null>(null);
  const [state, setState] = useState<string>('disconnected');
  const [micEnabled, setMicEnabled] = useState(true);
  const [transcripts, setTranscripts] = useState<TranscriptionSegment[]>([]);
  const [error, setError] = useState<string>();

  useEffect(() => {
    voiceRef.current = new XpectrumVoice({
      baseUrl: import.meta.env.VITE_VOICE_BASE_URL,
      apiKey: import.meta.env.VITE_VOICE_API_KEY,
      agentName: import.meta.env.VITE_VOICE_AGENT_NAME,
    });
    return () => voiceRef.current?.destroy();
  }, []);

  const connect = useCallback(async () => {
    if (!voiceRef.current) return;
    setError(undefined);
    setTranscripts([]);

    await voiceRef.current.connect({
      onConnected: () => setState('connected'),
      onDisconnected: () => setState('disconnected'),
      onConnectionStateChanged: (s) => setState(s),
      onTranscription: (seg) => {
        setTranscripts(prev => {
          const idx = prev.findIndex(t => t.id === seg.id);
          if (idx >= 0) {
            const updated = [...prev];
            updated[idx] = seg;
            return updated;
          }
          return [...prev, seg];
        });
      },
      onError: (err) => setError(err.message),
    });
  }, []);

  const disconnect = useCallback(async () => {
    await voiceRef.current?.disconnect();
  }, []);

  const toggleMic = useCallback(async () => {
    const next = !micEnabled;
    await voiceRef.current?.setMicEnabled(next);
    setMicEnabled(next);
  }, [micEnabled]);

  return {
    state,
    micEnabled,
    transcripts,
    error,
    connect,
    disconnect,
    toggleMic,
    isConnected: state === 'connected',
  };
}

5. Simple Voice Call Component

src/components/SimpleVoiceCall.tsx
import { useXpectrumVoice } from '../hooks/useXpectrumVoice';

export default function SimpleVoiceCall() {
  const {
    state,
    micEnabled,
    transcripts,
    error,
    connect,
    disconnect,
    toggleMic,
    isConnected,
  } = useXpectrumVoice();

  return (
    <div style={{ maxWidth: 500, margin: '0 auto', padding: 20 }}>
      <h2>Voice Call</h2>

      {/* Status */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
        <div style={{
          width: 10, height: 10, borderRadius: '50%',
          background: isConnected ? '#10b981' : state === 'connecting' ? '#f59e0b' : '#9ca3af',
        }} />
        <span style={{ fontSize: 14, textTransform: 'capitalize' }}>{state}</span>
      </div>

      {/* Controls */}
      <div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
        {!isConnected ? (
          <button
            onClick={connect}
            disabled={state === 'connecting'}
            style={{
              padding: '12px 24px', borderRadius: 8, background: '#10b981',
              color: '#fff', border: 'none', cursor: 'pointer', fontSize: 14,
            }}
          >
            {state === 'connecting' ? 'Connecting...' : 'Start Call'}
          </button>
        ) : (
          <>
            <button
              onClick={disconnect}
              style={{
                padding: '12px 24px', borderRadius: 8, background: '#ef4444',
                color: '#fff', border: 'none', cursor: 'pointer', fontSize: 14,
              }}
            >
              End Call
            </button>
            <button
              onClick={toggleMic}
              style={{
                padding: '12px 24px', borderRadius: 8,
                background: micEnabled ? '#6b7280' : '#f59e0b',
                color: '#fff', border: 'none', cursor: 'pointer', fontSize: 14,
              }}
            >
              {micEnabled ? 'Mute' : 'Unmute'}
            </button>
          </>
        )}
      </div>

      {/* Error */}
      {error && <p style={{ color: '#ef4444', fontSize: 14 }}>Error: {error}</p>}

      {/* Transcription */}
      <div style={{
        border: '1px solid #e5e7eb', borderRadius: 12,
        padding: 16, minHeight: 200, maxHeight: 400, overflowY: 'auto',
      }}>
        {transcripts.length === 0 && (
          <p style={{ color: '#9ca3af', textAlign: 'center', marginTop: 60 }}>
            Transcription will appear here during the call
          </p>
        )}
        {transcripts.map((t) => (
          <p key={t.id} style={{ margin: '6px 0', fontSize: 14, opacity: t.isFinal ? 1 : 0.5 }}>
            <strong style={{ color: t.speaker === 'user' ? '#7c3aed' : '#10b981' }}>
              {t.speaker}:
            </strong>{' '}
            {t.text}
          </p>
        ))}
      </div>
    </div>
  );
}

6. Putting It Together — App.tsx

src/App.tsx
import SimpleChat from './components/SimpleChat';
import SimpleVoiceCall from './components/SimpleVoiceCall';
import { useState } from 'react';

export default function App() {
  const [tab, setTab] = useState<'chat' | 'voice'>('chat');

  return (
    <div style={{ fontFamily: 'system-ui, sans-serif' }}>
      {/* Tab switcher */}
      <div style={{
        display: 'flex', gap: 0, borderBottom: '1px solid #e5e7eb',
        maxWidth: 600, margin: '0 auto',
      }}>
        <button
          onClick={() => setTab('chat')}
          style={{
            flex: 1, padding: '12px', border: 'none', cursor: 'pointer',
            background: 'transparent', fontSize: 14, fontWeight: 600,
            borderBottom: tab === 'chat' ? '2px solid #7c3aed' : '2px solid transparent',
            color: tab === 'chat' ? '#7c3aed' : '#6b7280',
          }}
        >
          Chat
        </button>
        <button
          onClick={() => setTab('voice')}
          style={{
            flex: 1, padding: '12px', border: 'none', cursor: 'pointer',
            background: 'transparent', fontSize: 14, fontWeight: 600,
            borderBottom: tab === 'voice' ? '2px solid #7c3aed' : '2px solid transparent',
            color: tab === 'voice' ? '#7c3aed' : '#6b7280',
          }}
        >
          Voice
        </button>
      </div>

      {/* Content */}
      {tab === 'chat' ? <SimpleChat /> : <SimpleVoiceCall />}
    </div>
  );
}