Typescript Live Chat: React and Socket.io with RxJS Event Handling

How to do real-time chat with Typescript: Part 2 of 2

This article walks through the process of developing a live chat solution in Typescript, utilising React, Socket.io and RxJS for event handling. The full solution discussed here is available on Github to browse and demo.

This is a continuation of Part 1, where the project was introduced and a backend Express server was set up to listen to incoming Socket.io events.

The client side of a chat application is arguably more complex than the server side; where the server’s job is primarily to emit Socket.io events and handle connected clients, the front-end has a more challenging role: to handle those events and display them in a coherent way to the end user.

However, we have some great tooling available in the open source community to achieve this. More specifically, we will cover the following topics:

  • Typescript Create React App setup: We will briefly document the project dependencies and setup instructions.
  • Socket.io Client: The Socket.io client, installed via the socket.io-client package, is the mechanism used to emit and receive events to and from the backend server. We will construct a SocketService class to handle the logic of the Socket client, from instantiation, emitting events and disconnecting from the server.

Note: Disconnecting from the server will be done inside the componentWillUnmount() React lifecycle method, giving the client an opportunity to disconnect upon redirecting to a different page, or just closing the browser window.

  • A context object for SocketService: A context provider will allow any component within the application to leverage the SocketService.
  • Connecting Socket.io events to an RxJS Observable: Although a complex framework for newcomers, we will keep things simple here by simply subscribing to an incoming Socket.io message event within our React app. By subscribing to events, we can fire code to update our app as those events are emitted in an asynchronous manner.
  • Sending messages and updating chat state: We’ll set up a simple input for typing new messages, and a submit handler for emitting them to the server side. Upon receiving new message events, the subscription callback function will update <App />’s component state, triggering a re-render and updating the chat window.

Project Setup

A template branch of the project is available on Github, providing a skeleton of the client side. This branch is just a slightly modified Create React App boilerplate for our client, styled to display incoming chat messages.

Either clone the project to follow with this walkthrough or install a new Typescript Create React App with the required dependencies:

# clone repo
git clone https://github.com/rossbulat/ts-live-chat-demo.git
cd ts-live-chat-demo/client
git checkout template
yarn
# or create new CRA project
npx create-react-app client --typescript
cd client
yarn add socket.io-client @types/socket.io-client styled-components @types/styled-components

For a fresh project, the socket.io-client and styled-components packages, as well as their types, have been added as project dependencies.

The tsconfig.json file being used is unchanged from the generated Create React App file. Most is standardised configuration for compiling a front-end app, but there are a couple of fields that I found interesting here:

  • The lib field specifies library files to be included at compilation time, that consist of type definitions. The various libs can be found here.
  • The target spec is es5. This is different to the backend config, which is es6. Backend servers run within a node environment we control, so we can confidently use the latest ECMAScript standards there.
  • The esnext module is being used, and moduleResolution is set to node, which will almost always be set to this value. moduleResolution determines the way modules are discovered within the project, and as the name suggests, a node module resolution value mimics the way NodeJS finds modules. Read more on Module Resolution here.

Note: All the Typescript compiler options are documented here.

A couple of types have been defined in a separate types.ts file to be used with the chat service; let’s briefly acknowledge them:

// src/types.tsexport interface ChatMessage {
author: string;
message: string;
}
export interface ChatState {
input: string;
messages: ChatMessage[];
}

ChatMessage is identical to the backend counterpart used by Socket.io events. It consists of author and message string properties. ChatState defines the structure of the <App /> component state, that will determine the state of the live chat itself.

input is updated as the user is typing the message to be submitted, whereas messages[] is an array of ChatMessages received from Socket.io emits. We will see exactly how received socket events are added to this state further down.

With our types defined, we can now construct the Socket.io service itself, implemented as a SocketService class. Let’s break down how exactly it works.

Socket Service Class

SocketService is quite a simple class, but has multiple purposes for managing a connection to the server. Concretely, it needs to:

  • Hold the socket client privately — we only wish the class instance to manage the connection via internal functions
  • Be able to connect and disconnect from the server
  • Send a ChatMessage to the server so it can emit that message to all connected clients
  • Receive incoming ChatMessage’s and return them

The class is only 29 lines of code, so we are able to break it down line by line here. The imports consist of the io object, responsible for managing the Socket.io connection, and ChatMessage type we defined earlier:

import io from 'socket.io-client';
import { ChatMessage } from './types';

import { fromEvent, Observable } from 'rxjs';
export class SocketService {
...
}

The class only has one private property, socket:

private socket: SocketIOClient.Socket = {} as SocketIOClient.Socket;

What is interesting about this is that we have used a type assertion, using as, to explicitly cast the default socket value, an empty object, as SocketIOClient.Socket.

Why have we done this? Because we do not wish to initialise a socket connection as soon as the class instantiates. Instead, we have defined a separate method, init(), that does exactly that:

// connecting to socket service on port 8080public init (): SocketService {
this.socket = io('localhost:8080');
return this;
}

Note: Port 8080 was configured as the port to open socket connections in the previous article.

It is most likely that you will not wish to connect to the service immediately. Perhaps there is a chat room to enter, or a confirmation button that must be pressed before starting a live chat.

init() returns the class instance itself, allowing us to chain the method to the instantiated object:

const chat = new SocketService();
chat.init();

In our case for this discussion, we instantiate a SocketService at the top level of our app and place it in a context. It is within <App /> that we then call init(), once we are ready to start accepting and receiving messages. We’ll visit how this context is set up in the next section.

Two methods of SocketService have been defined to send and receive messages to and from the server: send() and onMessage().

send() utilises socket’s emit() function to push a message to the backend server to then broadcast to connected clients:

// send a message for the server to broadcast  public send (message: ChatMessage): void {
this.socket.emit('message', message);
}

The message parameter here expects a ChatMessage object, consisting of an author and message property, which is then sent to the backend.

onMessage() returns an RxJS Observable, using socket’s message event as its data source.

Let’s examine this method further. A couple of rxjs functions have been imported into SocketService to set up an Observable from an event:

import { fromEvent, Observable } from 'rxjs';

An Observable in RxJS can be thought of as an object that relays data from a data source to its subscribers. In our case, that data source is a Socket.io event — and RxJS provides a useful function specifically for attaching this type of data source to an observable: fromEvent.

To use the official definition, fromEvent turns an event into an observable sequence. The official docs demonstrate how fromEvent is used with a mouse click event. We are simply adopting the same technique for a socket message event.

This is exactly what the SocketService onMessage() method does:

// link message event to rxjs data sourcepublic onMessage (): Observable<ChatMessage> {
return fromEvent(this.socket, 'message');
}

Note that we are returning an Observable of type ChatMessage. Observable only knows that we are observing some kind of data stream, therefore we have to explicitly tell it what type of data we are expecting via its generic type — a ChatMessage object.

The method itself returns the Observable resulting from fromEvent(), that takes a target object and type of event that derives from it. Just like a mouse click event is emitted from an element such as a button, the message event is emitted from this.socket.

The last method in SocketService disconnects from the service:

// disconnect - used when unmountingpublic disconnect (): void {
this.socket.disconnect();
}

This method can be used when a component unmounts, or in situations where a user “leaves” a live chat, perhaps by pressing a button.

A SocketService instance can be instantiated and used within any component. Let’s next see how this is done, with it’s very own context provider.

Socket Service Context

A separate file, ChatContext.ts, defines a React context specifically for our socket service. This is effectively achieved with one line of code:

// import context APIs and SocketServiceimport React from 'react';
import { SocketService } from './SocketService';
// create new contextexport const ChatContext: React.Context<SocketService> = React.createContext(new SocketService());

To keep Typescript happy, a new SocketService instance is supplied as the default value of our context. We do not need to worry about this default value being functional; our init() method has to be called in order to initialise the socket connection itself.

ChatContext can now be imported and embedded into the component tree via <ChatContext.Provider>. This is exactly what we’ve done in index.tsx, giving the entire app access to our SocketService:

// src/index.tsx...import { SocketService } from './SocketService';
import { ChatContext } from './ChatContext';
const chat = new SocketService();ReactDOM.render(
<ChatContext.Provider value={chat}>
<App />
</ChatContext.Provider>
, document.getElementById('root'));

With an instantiated SocketService now readily available, we can connect to our socket server and send messages from within any component. This has been done within <App /> for this demonstration.

Sending Messages using SocketService

The live chat itself is coded within App.tsx. Let’s break down the various elements that make <App /> a functional live chat interface.

First and foremost, we have brought our context into scope on the first line of the component:

...
class App extends React.Component {
static contextType = ChatContext;
...
}

Now from anywhere within App, our SocketService can be reached with this.context. Before we use the context, the component state is defined:

state: ChatState = {
messages: [
{
message: 'Welcome! Type a message and press Send Message to continue the chat.',
author: 'Bot'
}
],
input: ''
}

App’s state must conform to the ChatState interface defined earlier, consisting on a range of messages and an input value corresponding to what the user is typing.

A default message is included to introduce the that, with Bot being the author. This message is displayed straight away upon the initial render.

componentDidMount() plays an important role within the component. It has two purposes:

  • It calls our SocketService’s init() method, connecting to our socket service
  • Subscribes to the observable we returned by SocketService’s onMessage(), and defines a callback function for incoming events

This is the implementation:

componentDidMount () {

// connect to socket from our context
this.context.init();
// retrieve observable
const observable = this.context.onMessage();
// subscribe to observable
observable.subscribe((m: ChatMessage) => {

// add incoming message to state
let messages = this.state.messages;
messages.push(m);

this.setState({ messages: messages });
});
}

This syntax is quite self explanatory, the most ambiguity coming from the observable.subscribe function. Within it, a callback function is defined that is fired whenever a new ChatMessage is received from the backend, via the m parameter.

From here the component state is updated with that message appended to the current messages saved in state. This triggers a re-render of the component, and our live chat UI is updated with the latest messages.

Unmounting is equally important as mounting for tidying up open websocket connections. Also included is the componentWillUnmount() lifecycle method for disconnecting from the socket service:

componentWillUnmount () {
this.context.disconnect();
}

The render() method is relatively straight forward. It provides UX for displaying messages and handling message input via a text box. Let’s take a look at some key areas of render().

Note: The full render method can be viewed in App.tsx here on Github.

User input is managed with a handleInput() method, that is called as the user is typing:

// update `input` state as user is typingconst updateInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ input: e.target.value });
}
...return(
...
<input
className="App-Textarea"
placeholder="Type your messsage here..."
onChange={updateInput}
value={this.state.input}
/>
...
);

Another function is defined for handling a message submission, called when the Send Message button is pressed:

// emitting a new message to the server on button pressconst handleMessage = (): void => {

// author name is hardcoded for simplicity
const author: string = 'Ross';

// call send() if `input` state is provided
if (this.state.input !== '') {
this.context.send({
message: this.state.input,
author: author
});
// reset `input` state
this.setState({ input: '' });
}
};
return(
...
<button onClick={() => { handleMessage() }}>
Send Message
</button>
...
);

It is also worth documenting how messages are rendered. this.state.messages is mapped, allowing us to return message UI directly in render’s return statement:

let msgIndex = 0;
return (
...
<div className="App-chatbox">
{this.state.messages.map((msg: ChatMessage) => {
msgIndex++;

return (
<div key={msgIndex}>
<p>{msg.author}</p>
<p>
{msg.message}
</p>
</div>
);
})}
</div>
)

As your messages UI and ChatMessage objects become more complex, it will be more advisable to split these elements within separate components. For example, incorporating ChatBox and ChatMessage components.

In Summary

This concludes our discussion on this live chat solution. In summary, we have:

  • Initiated a Typescript based Create React App project and installed socket.io-client as a means of connecting to the backend server
  • Implemented types specifically for the chat service, in addition to a SocketService class, whose purpose is to manage the socket connection and handle incoming events from the server
  • Defined a ChatContext object to provide a socket service to the entire application
  • Utilised RxJS’s fromEvent observable, and subscribing to it as a means to react to new message events as they are emitted to connected clients. Once received, the subscriber callback function adds the incoming message to App’s state.
  • Utilised lifecycle methods to manage the socket connection. componentDidMount() is also a useful place for subscribing to observables.
  • Tied everything together in <App />, which displays live chat messages and allows the user to submit new messages. <App />’s state persists incoming messages, appending them to the messages array as they are received.

To refer back to Part 1 where we walked through the socket server, visit the following article:

Programmer and Author. Director @ JKRBInvestments.com. Creator of LearnChineseGrammar.com for iOS.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store