Adding Rich Interactivity to Your Messaging App with React and WebRTC

New
9 min read
Raymond F
Raymond F
Published February 28, 2025

Chat applications have moved well beyond the basic IRC messaging they started as. With chat platforms such as Stream, you get access to a whole array of rich features: reactions, presence notifications, file uploads, and slash commands. These features change straightforward chat into meaningful real-time applications.

But how do these work under the hood? By using WebRTC. WebRTC is the protocol that powers modern real-time communication on the web, enabling direct browser-to-browser connections that can handle everything from video calls to collaborative drawing. Unlike traditional client-server architectures, where all data must flow through a central server, WebRTC creates peer-to-peer connections that allow faster, more responsive user interactions.

Here, you’ll learn more about WebRTC and chat interactivity by building a React application that uses the protocol to allow users to chat, add rich text, see who is online and active, and even draw pictures together.

Using WebRTC for Rich Interactivity

We won’t discuss WebRTC in detail here. Instead, check out our WebRTC tutorialf series to learn about the protocol's specifics and how it powers real-time messaging on the web.

Let’s concentrate on how the rich features of chat work with WebRTC. WebRTC's architecture is built around three main types of data transfer:

  1. Media streams for audio and video
  2. Reliable ordered data channels for critical information
  3. Unreliable data channels for time-sensitive updates.

This flexibility in data transport allows developers to optimize the implementation of different types of chat features. For example, typing indicators and presence updates can use unreliable channels since occasional drops won't impact the user experience. In contrast, message delivery and file transfers can use reliable channels to ensure data integrity.

A single WebRTC connection can maintain separate channels for chat messages, file transfers, typing indicators, and presence updates, each with its own quality of service parameters. This multiplexing capability means slow operations like file uploads won't block or delay other real-time features.

To build out our messaging app, you’ll need two components:

  1. A “signaling” server to establish initial connections and handle signaling for WebRTC. This server is the intermediary that helps clients discover each other and exchange the information needed to establish direct peer-to-peer connections.
  2. A WebRTC client(s) that implements all the rich functionality users expect in a modern chat application. The client needs to handle basic messaging and manage real-time features like typing indicators, presence updates, and collaborative tools. All of this runs directly in the browser, leveraging WebRTC's peer-to-peer capabilities to create responsive, interactive experiences.

We’ll build and start the server first, then move on to the clients.

Building Our WebRTC Server For Real-Time Communication

The signaling server maintains a registry of connected users and helps exchange connection details like network addresses and media capabilities. Once the WebRTC connection is established between peers, most communication will happen directly between them, but the server remains available as a backup for handling reconnection scenarios or network changes.

The server code is simple. You will need Node.js installed for this example, though the server could be built in your favorite backend language. Then, you will need just two dependencies:

npm install ws uuid 
  • ws: A lightweight and efficient WebSocket client and server implementation for Node.js that enables real-time bidirectional communication.
  • uuid: A library that generates unique identifiers, which we'll use to assign distinct IDs to each connected client for reliable message routing.

Create a file called server.js and add this code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const WebSocket = require('ws'); const { v4: uuidv4 } = require('uuid'); const wss = new WebSocket.Server({ port: 3001 }); const clients = new Map(); // Map<ws, clientId> function broadcastOnlineUsers() { const allIds = [...clients.values()]; // array of all client IDs // Send to everyone for (const [ws, id] of clients) { ws.send(JSON.stringify({ type: 'online-users', users: allIds })); } } wss.on('connection', (ws) => { const clientId = uuidv4(); clients.set(ws, clientId); console.log(`New client connected: ${clientId}`); // Send the client their ID ws.send(JSON.stringify({ type: 'id', id: clientId })); // Broadcast updated online user list broadcastOnlineUsers(); ws.on('message', (message) => { let data; try { data = JSON.parse(message); } catch (err) { console.error('Invalid message from client:', err); return; } // If the message has a "target" ID, route it there if (data.target) { // find the ws for that target ID const targetEntry = [...clients].find(([socket, id]) => id === data.target); if (targetEntry) { const [targetWS, _] = targetEntry; // forward the message, plus "from" field targetWS.send(JSON.stringify({ ...data, from: clientId })); } } }); ws.on('close', () => { console.log(`Client disconnected: ${clientId}`); clients.delete(ws); broadcastOnlineUsers(); }); }); console.log('Signaling server running on ws://localhost:3001');

This establishes a WebSocket server (using ws) that manages client connections and routes messages between peers. When clients connect, they receive a unique ID, and the server maintains a mapping between WebSocket connections and these IDs in the clients Map. The broadcastOnlineUsers function ensures all connected clients stay updated about who else is available for potential WebRTC connections.

The data flowing through this server follows a consistent JSON format that includes message type, sender, target, and payload information. For example:

javascript
1
2
3
4
5
6
7
8
{ type: 'offer', // Type of signaling message (offer, answer, ice-candidate) target: '123e4567-...', // UUID of the target peer payload: { // The actual WebRTC session description or candidate sdp: '...', type: 'offer' } }

The important thing is that the messages can be anything. The server acts as a dumb relay, forwarding whatever data it receives without needing to understand or modify it. This flexibility allows it to handle any signaling data WebRTC needs to exchange during connection setup.

Start this server with:

node server.js

Then, you can move on to the clients.

Creating Our React Clients For Rich Interactivity

With WebRTC, the magic is in the client. This is the point: it is a client-to-client connection.

Create a new React project using vite:

npm create vite@latest react-webrtc -- --template react

You need to make a few minor changes to the default app. First, change the code in App.jsx to:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useState } from 'react' import WebRTCChat from './components/WebRTCChat' import './App.css' function App() { return ( <> <h1>WebRTC Chat Application</h1> <div className="card"> <WebRTCChat /> </div> </> ) } export default App

Then, create a WebRTCChat.jsx file in src/components. This is where all our code for our WebRTC client is going to live. We won’t share the entire client code here; instead, we will concentrate on the rich feature code. You can find all the client and server code in this repo.

Presence

The presence system demonstrates how WebRTC and WebSocket signaling work together to maintain an accurate list of available chat partners. Let's examine how this works across both the server and client components:

On the server side, we maintain a simple but effective registry of connected clients using a Map:

javascript
1
2
3
4
5
6
7
8
9
10
11
const clients = new Map(); // Map<ws, clientId> function broadcastOnlineUsers() { const allIds = [...clients.values()]; for (const [ws, id] of clients) { ws.send(JSON.stringify({ type: 'online-users', users: allIds })); } }
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

This code maintains a mapping between WebSocket connections and unique client IDs. Whenever the number of connected users changes, the server broadcasts the updated list to all clients, ensuring everyone has the latest presence information.

On the client side, we handle these presence updates through our WebSocket connection:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const [onlineUsers, setOnlineUsers] = useState([]); // Presence state const [connectionStatus, setConnectionStatus] = useState('Disconnected'); ws.current.onmessage = (event) => { const data = JSON.parse(event.data); switch (data.type) { case 'id': // Our ID from the server setClientId(data.id); setConnectionStatus(`Connected (Your ID: ${data.id})`); break; case 'online-users': // Updated presence list setOnlineUsers(data.users); break; // ... other cases } };

The presence information is then displayed in the UI, showing users who's available for chat and allowing them to initiate connections:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{/* Presence / Online Users */} <div style={{ marginBottom: 10 }}> <strong>Online Users:</strong> <ul> {onlineUsers.map((u) => ( <li key={u} style={{ margin: '5px 0' }}> {u === clientId ? ( <span>{u} (You)</span> ) : ( <> {u}{' '} <button onClick={() => initiateConnection(u)}>Connect</button> </> )} </li> ))} </ul> </div>

This system ensures accurate presence information through several key mechanisms:

  1. Immediate updates when users connect (through the server's connection handler)
  2. Immediate updates when users disconnect (through the server's close handler)
  3. UI that clearly shows your own ID and available peers
  4. Integration with the WebRTC connection system through the initiateConnection function

Typing Indicators

The typing indicator feature shows how WebRTC's unreliable data channels handle real-time state updates. Here's how it works:

javascript
1
2
3
4
5
6
7
8
9
10
11
const sendTypingEvent = () => { if (dataChannel.current && dataChannel.current.readyState === 'open') { // Send minimal JSON to indicate typing dataChannel.current.send(JSON.stringify({ type: 'typing' })); // Also route via signaling for reliability/consistency ws.current.send(JSON.stringify({ type: 'typing', target: peerId })); } };

This sends typing events through both the WebRTC data channel and the signaling server. The dual-channel approach ensures that typing indicators remain responsive even under challenging network conditions.

The code uses a debouncing pattern with setTimeout to prevent flooding the connection with too many events:

javascript
1
2
3
4
5
6
7
8
9
10
11
const handleInputChange = (e) => { setInputMessage(e.target.value); if (!peerId) return; if (typingTimeout.current) clearTimeout(typingTimeout.current); sendTypingEvent(); // Wait 1 second of no typing before sending "stop-typing" typingTimeout.current = setTimeout(() => { sendStopTypingEvent(); }, 1000); };

Emoji Integration

The emoji picker demonstrates an important principle in rich chat applications: combining UI components with WebRTC's data transport. The code uses the @emoji-mart library to provide a polished emoji selection interface while leveraging our existing message infrastructure for delivery:

javascript
1
2
3
4
5
6
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const handleEmojiSelect = (emoji) => { setInputMessage(prev => prev + emoji.native); setShowEmojiPicker(false); };

This takes advantage of how WebRTC handles Unicode characters. Since WebRTC data channels support UTF-8 encoding, emoji characters can be transmitted like regular text messages without special handling.

The emoji selection UI is integrated into the message input flow:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
<div> <input type="text" value={inputMessage} onChange={handleInputChange} onKeyPress={(e) => { if (e.key === 'Enter') sendMessage() }} placeholder="Type a message..." style={{ width: '70%', marginRight: 10 }} /> <button onClick={sendMessage}>Send</button> <button onClick={() => setShowEmojiPicker(!showEmojiPicker)}></button> </div>

When the emoji picker is shown, it appears in a floating layer above the chat:

javascript
1
2
3
4
5
6
7
8
9
{showEmojiPicker && ( <div style={{ position: 'absolute', bottom: '80px', right: '20px', zIndex: 1 }}> <Picker data={data} onEmojiSelect={handleEmojiSelect} theme="light" /> </div> )}

When an emoji is selected, it's simply appended to the input message text. This means our existing message-sending infrastructure handles emoji delivery without modification:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
const sendMessage = () => { if ( dataChannel.current && dataChannel.current.readyState === 'open' && inputMessage.trim() ) { // Emoji characters are sent just like regular text dataChannel.current.send(inputMessage); setMessages(prev => [...prev, { content: inputMessage, sender: 'Me' }]); setInputMessage(''); sendStopTypingEvent(); } };

This shows one of WebRTC's strengths: its ability to handle various types of data without requiring special protocols or transformations. For example, the same data channel that carries text messages can carry emojis without additional configuration, making adding rich features to your chat application easy.

![][image2]

Interactive Whiteboard

The whiteboard feature shows WebRTC's ability to handle continuous real-time updates. It uses the canvas API for drawing and sends coordinate data through the connection:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const drawMouseMove = (e) => { if (!drawing) return; const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Local draw drawLine({ x1: lastX, y1: lastY, x2: x, y2: y }); // Send to peer ws.current.send(JSON.stringify({ type: 'draw', coords: { x1: lastX, y1: lastY, x2: x, y2: y }, target: peerId })); lastX = x; lastY = y; };

Event & Connection Handling Architecture

Let’s look at how the central event handling system routes different types of messages to their appropriate handlers. This architecture makes it easy to add new features by simply adding new message types:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ws.current.onmessage = (event) => { const data = JSON.parse(event.data); switch (data.type) { case 'typing': handlePeerTyping(true); break; case 'draw': drawLine(data.coords); break; case 'file-chunk': handleFileChunk(data); break; // ... other cases } };

This event-driven architecture creates a flexible, maintainable system in which each feature operates independently but integrates with the others. Following the open-closed principle of software design, the switch statement pattern makes it simple to add new message types without modifying existing code.

The WebRTC connection setup and maintenance are handled through clear separation of concerns. The createPeerConnection function establishes the initial connection:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const createPeerConnection = () => { const configuration = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }; localConnection.current = new RTCPeerConnection(configuration); localConnection.current.onicecandidate = (event) => { if (event.candidate) { ws.current.send(JSON.stringify({ type: 'ice-candidate', candidate: event.candidate, target: peerId })); } }; };

This connection management pattern demonstrates how WebRTC's complexity can be effectively managed through careful separation of concerns. The createPeerConnection function encapsulates all the low-level WebRTC setup details, while the message handling system above it deals with application-specific features.

This layered approach makes the code more maintainable and extensible, allowing new features to be added without needing to understand all the intricacies of WebRTC's connection management.

Rich Interactivity is Table Stakes

If you’re creating a messaging application, then it's no longer enough to have simple chat. Someone will want emojis; someone else will want to know who is online. Eventually, people will want uploads and audio and video.

All these are options when you use WebRTC and React (and/or Stream). By leveraging WebRTC's peer-to-peer architecture and React's component-based structure, you can create real-time experiences that meet users' growing expectations for rich, interactive communication.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->