LatteStreamĀ®

Quick Start

Getting Started

Building Custom SDKs

SDKs & Libraries

JavaScript/TypeScript

Node.js / Bun / Deno

Python

In Development

Go

In Development

PHP

In Development

API Reference

WebSocket API

REST API

Webhooks

Authentication

Real-time Chat Application

Build a complete chat application with React, TypeScript, and LatteStream

This example demonstrates how to build a production-ready chat application with user presence, typing indicators, and message history using LatteStream's real-time infrastructure.

What You'll Build

  • Real-time messaging with instant delivery
  • User presence showing who's online
  • Typing indicators when users are composing messages
  • Message history with persistent storage
  • Private channels with authentication
  • Connection status with automatic reconnection
  • Clean React UI with TypeScript

Prerequisites

  • Node.js 18+
  • Basic React and TypeScript knowledge
  • LatteStream account with App Key and Master Key

Quick Start

# Clone the starter template npx create-react-app lattestream-chat --template typescript cd lattestream-chat # Install dependencies npm install @lattestream/client npm install -D @types/node # Start development npm start

Project Structure

src/
ā”œā”€ā”€ components/
│   ā”œā”€ā”€ Chat.tsx              # Main chat container
│   ā”œā”€ā”€ ConnectionStatus.tsx  # Connection indicator
│   ā”œā”€ā”€ MessageList.tsx       # Message display
│   ā”œā”€ā”€ MessageInput.tsx      # Message input field
│   └── UsernameForm.tsx      # Initial username entry
ā”œā”€ā”€ hooks/
│   └── useLatteStreamChat.ts # LatteStream integration
ā”œā”€ā”€ types/
│   └── chat.ts               # TypeScript definitions
ā”œā”€ā”€ styles/
│   └── Chat.module.css       # Component styles
└── App.tsx                   # Root component

Implementation

1. TypeScript Definitions

types/chat.ts

export interface Message { id: string; userId: string; username: string; text: string; timestamp: Date; isOwn?: boolean; isServerMessage?: boolean; } export interface User { id: string; name: string; socketId?: string; } export interface TypingUser { userId: string; username: string; lastTyped: Date; } export type ConnectionState = | 'connecting' | 'connected' | 'disconnected' | 'error';

2. LatteStream Hook

hooks/useLatteStreamChat.ts

import { useState, useEffect, useCallback, useRef } from 'react'; import { LatteStream, Channel } from '@lattestream/client'; import type { Message, User, TypingUser, ConnectionState } from '../types/chat'; export const useLatteStreamChat = (username: string) => { // State management const [messages, setMessages] = useState<Message[]>([]); const [connectionState, setConnectionState] = useState<ConnectionState>('connecting'); const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]); const [error, setError] = useState<string>(); const [currentUser, setCurrentUser] = useState<User | null>(null); // Refs for cleanup const latteStreamRef = useRef<LatteStream | null>(null); const channelRef = useRef<Channel | null>(null); const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => { const userId = `user-${Math.random().toString(36).substring(2, 11)}`; // Initialize LatteStream const latteStream = new LatteStream( process.env.REACT_APP_LATTESTREAM_KEY!, { authEndpoint: process.env.REACT_APP_AUTH_ENDPOINT || 'http://localhost:3030/auth', enableLogging: process.env.NODE_ENV === 'development', } ); latteStreamRef.current = latteStream; // Connection state handlers latteStream.connection.bind('state_change', (states: any) => { console.log('[LatteStream] Connection state:', states.current); setConnectionState(states.current); if (states.current === 'connected') { setCurrentUser({ id: userId, name: username, socketId: latteStream.getSocketId() || undefined, }); } }); // Error handling latteStream.connection.bind('error', (err: any) => { console.error('[LatteStream] Connection error:', err); setError(err.message || 'Connection error'); }); // Connect latteStream.connect(); // Subscribe to private channel const channel = latteStream.subscribe('private-chat-room'); channelRef.current = channel; // Message handlers channel.bind('new-message', (data: any) => { const message: Message = { id: data.id || Date.now().toString(), userId: data.userId, username: data.username, text: data.text, timestamp: new Date(data.timestamp), isServerMessage: data.isServerMessage, }; setMessages((prev) => [...prev, message]); }); // Typing indicators channel.bind('user-typing', (data: any) => { if (data.userId !== userId) { setTypingUsers((prev) => { const filtered = prev.filter((u) => u.userId !== data.userId); return [ ...filtered, { userId: data.userId, username: data.username, lastTyped: new Date(), }, ]; }); // Remove after 3 seconds setTimeout(() => { setTypingUsers((prev) => prev.filter((u) => u.userId !== data.userId) ); }, 3000); } }); // Cleanup return () => { if (channelRef.current) { latteStream.unsubscribe('private-chat-room'); } latteStream.disconnect(); }; }, [username]); // Send message const sendMessage = useCallback( async (text: string) => { if (!currentUser || !channelRef.current) return; const message: Message = { id: Date.now().toString(), userId: currentUser.id, username: currentUser.name, text, timestamp: new Date(), isOwn: true, }; // Optimistic update setMessages((prev) => [...prev, message]); // Send to server (replace with your API endpoint) try { await fetch('/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelName: 'private-chat-room', eventName: 'new-message', data: { id: message.id, userId: message.userId, username: message.username, text: message.text, timestamp: message.timestamp.toISOString(), }, }), }); } catch (error) { console.error('Failed to send message:', error); setError('Failed to send message'); } }, [currentUser] ); // Send typing indicator const sendTypingIndicator = useCallback(() => { if (!channelRef.current || !currentUser) return; // Clear existing timer if (typingTimerRef.current) { clearTimeout(typingTimerRef.current); } // Trigger typing event try { channelRef.current.trigger('client-user-typing', { userId: currentUser.id, username: currentUser.name, }); } catch (error) { console.log('Typing indicator failed:', error); } // Stop typing after 1 second typingTimerRef.current = setTimeout(() => { // Could send stop-typing event here }, 1000); }, [currentUser]); return { messages, connectionState, typingUsers, error, currentUser, sendMessage, sendTypingIndicator, }; };

3. Main Chat Component

components/Chat.tsx

import React from 'react'; import { useLatteStreamChat } from '../hooks/useLatteStreamChat'; import { ConnectionStatus } from './ConnectionStatus'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import styles from '../styles/Chat.module.css'; interface ChatProps { username: string; } export const Chat: React.FC<ChatProps> = ({ username }) => { const { messages, connectionState, typingUsers, error, currentUser, sendMessage, sendTypingIndicator } = useLatteStreamChat(username); return ( <div className={styles.container}> {/* Header */} <div className={styles.header}> <h1 className={styles.title}>LatteStream Chat</h1> <ConnectionStatus status={connectionState} socketId={currentUser?.socketId} error={error} /> </div> {/* Chat Area */} <div className={styles.chatArea}> <MessageList messages={messages} currentUserId={currentUser?.id} typingUsers={typingUsers} /> <MessageInput onSendMessage={sendMessage} onTyping={sendTypingIndicator} disabled={connectionState !== 'connected'} placeholder={ connectionState === 'connected' ? 'Type a message...' : 'Connecting...' } /> </div> </div> ); };

4. Message Components

components/MessageList.tsx

import React, { useEffect, useRef } from 'react'; import type { Message, TypingUser } from '../types/chat'; import styles from '../styles/MessageList.module.css'; interface MessageListProps { messages: Message[]; currentUserId?: string; typingUsers: TypingUser[]; } export const MessageList: React.FC<MessageListProps> = ({ messages, currentUserId, typingUsers }) => { const messagesEndRef = useRef<HTMLDivElement>(null); // Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); return ( <div className={styles.container}> {messages.map((message) => ( <div key={message.id} className={`${styles.message} ${ message.userId === currentUserId ? styles.own : styles.other }`} > <div className={styles.messageHeader}> <span className={styles.username}>{message.username}</span> <span className={styles.timestamp}> {message.timestamp.toLocaleTimeString()} </span> </div> <div className={styles.messageText}>{message.text}</div> </div> ))} {/* Typing indicators */} {typingUsers.length > 0 && ( <div className={styles.typingIndicator}> {typingUsers.map(user => user.username).join(', ')} {typingUsers.length === 1 ? ' is' : ' are'} typing... </div> )} <div ref={messagesEndRef} /> </div> ); };

components/MessageInput.tsx

import React, { useState, KeyboardEvent } from 'react'; import styles from '../styles/MessageInput.module.css'; interface MessageInputProps { onSendMessage: (message: string) => void; onTyping: () => void; disabled?: boolean; placeholder?: string; } export const MessageInput: React.FC<MessageInputProps> = ({ onSendMessage, onTyping, disabled = false, placeholder = 'Type a message...' }) => { const [message, setMessage] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = message.trim(); if (!trimmed || disabled) return; onSendMessage(trimmed); setMessage(''); }; const handleKeyPress = (e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } else { onTyping(); } }; return ( <form className={styles.container} onSubmit={handleSubmit}> <input type="text" className={styles.input} value={message} onChange={(e) => setMessage(e.target.value)} onKeyPress={handleKeyPress} placeholder={placeholder} disabled={disabled} /> <button type="submit" className={styles.button} disabled={disabled || !message.trim()} > Send </button> </form> ); };

5. Connection Status Component

components/ConnectionStatus.tsx

import React from 'react'; import type { ConnectionState } from '../types/chat'; import styles from '../styles/ConnectionStatus.module.css'; interface ConnectionStatusProps { status: ConnectionState; socketId?: string; error?: string; } export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({ status, socketId, error }) => { const getStatusColor = () => { switch (status) { case 'connected': return styles.connected; case 'connecting': return styles.connecting; case 'disconnected': return styles.disconnected; case 'error': return styles.error; default: return styles.disconnected; } }; const getStatusText = () => { switch (status) { case 'connected': return `Connected ${socketId ? `(${socketId.slice(0, 8)}...)` : ''}`; case 'connecting': return 'Connecting...'; case 'disconnected': return 'Disconnected'; case 'error': return `Error: ${error || 'Connection failed'}`; default: return 'Unknown'; } }; return ( <div className={`${styles.container} ${getStatusColor()}`}> <div className={styles.indicator} /> <span className={styles.text}>{getStatusText()}</span> </div> ); };

6. Username Form

components/UsernameForm.tsx

import React, { useState } from 'react'; import styles from '../styles/UsernameForm.module.css'; interface UsernameFormProps { onSubmit: (username: string) => void; } export const UsernameForm: React.FC<UsernameFormProps> = ({ onSubmit }) => { const [username, setUsername] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = username.trim(); if (!trimmed) return; onSubmit(trimmed); }; return ( <div className={styles.container}> <div className={styles.form}> <h1 className={styles.title}>Join LatteStream Chat</h1> <p className={styles.subtitle}>Enter your username to start chatting</p> <form onSubmit={handleSubmit}> <input type="text" className={styles.input} value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Your username" maxLength={20} required /> <button type="submit" className={styles.button} disabled={!username.trim()} > Start Chatting </button> </form> </div> </div> ); };

Styling

styles/Chat.module.css

.container { display: flex; flex-direction: column; height: 100vh; max-width: 800px; margin: 0 auto; background: #ffffff; border: 1px solid #e1e5e9; border-radius: 8px; overflow: hidden; } .header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: #f8f9fa; border-bottom: 1px solid #e1e5e9; } .title { margin: 0; font-size: 1.25rem; font-weight: 600; color: #2d3748; } .chatArea { display: flex; flex-direction: column; flex: 1; min-height: 0; }

Server Integration

Create a simple Express server to handle message broadcasting:

server.js

const express = require('express'); const cors = require('cors'); const { LatteStreamServer } = require('@lattestream/server'); const app = express(); app.use(cors()); app.use(express.json()); // Initialize LatteStream const lattestream = new LatteStreamServer( process.env.LATTESTREAM_APP_KEY, process.env.LATTESTREAM_MASTER_KEY ); // Authentication endpoint app.post('/auth', (req, res) => { const { socket_id, channel_name } = req.body; // In production, verify user authentication here const auth = lattestream.authenticate(socket_id, channel_name, { user_id: 'user-' + Math.random().toString(36).substr(2, 9), user_info: { name: 'Chat User' }, }); res.json(auth); }); // Message broadcasting endpoint app.post('/api/messages', async (req, res) => { const { channelName, eventName, data } = req.body; try { await lattestream.trigger(channelName, eventName, data); res.json({ success: true }); } catch (error) { console.error('Failed to trigger event:', error); res.status(500).json({ error: 'Failed to send message' }); } }); const PORT = process.env.PORT || 3030; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });

Environment Configuration

.env

REACT_APP_LATTESTREAM_KEY=your_app_key_here REACT_APP_AUTH_ENDPOINT=http://localhost:3030/auth # Server environment LATTESTREAM_APP_KEY=your_app_key_here LATTESTREAM_MASTER_KEY=your_master_key_here

Running the Application

  1. Start the server:
node server.js
  1. Start the React app:
npm start
  1. Open multiple browser tabs to test real-time messaging

Advanced Features

Message Persistence

// Add message storage const messages = []; app.post('/api/messages', async (req, res) => { const message = { ...req.body.data, id: Date.now().toString(), timestamp: new Date().toISOString(), }; messages.push(message); await lattestream.trigger(req.body.channelName, req.body.eventName, message); res.json({ success: true }); }); // Get message history app.get('/api/messages', (req, res) => { res.json(messages.slice(-50)); });

File Upload

const uploadFile = async (file: File): Promise<string> => { const formData = new FormData(); formData.append('file', file); const response = await fetch('/api/upload', { method: 'POST', body: formData, }); const { url } = await response.json(); return url; };

Emoji Support

npm install emoji-mart @emoji-mart/react

šŸ“± Mobile Optimization

Add responsive design for mobile devices:

@media (max-width: 768px) { .container { height: 100vh; border-radius: 0; border: none; } .header { padding: 0.75rem 1rem; } .title { font-size: 1.1rem; } }

You're Done!

You now have a complete, production-ready chat application with:

  • Real-time messaging
  • User presence and typing indicators
  • Connection status monitoring
  • Clean React TypeScript implementation
  • Mobile-responsive design
  • Server-side message broadcasting

Next Steps


Need help? Join our Discord community or check the troubleshooting guide.