Real-Time Data Transfer Using Vue and Socket.IO [Part 2 of 3]
This project uses Node.js v14.15.1.
In the last post we covered setting up a simple server and client which communicate with each other using websockets. This post will focus on the creation of a chat room. The third and final post in this series will build upon the techniques that we’ll cover in this post to create a simple multiplayer game “Fill Frenzy!”.
Add Real-Time Chat
The first of the two main components we will cover will be the chat room. The chat room will have the following features:
- Support for multiple users,
- A list of all connected users,
- A history of all chat messages, and
- Notifications for when other users are typing.

The chat component will be lengthy and touch multiple files, so I will break it down into manageable sections as best I can.
Update the Server Code
Having all the server code in one file will become painful to manage, especially since we’ll be adding new code to handle more components of our app. Let’s try to structure it a little better, and then add our chat component.
- Create a new folder named
serverin the project root. - Move
server.jsinto theserverfolder, then rename it toindex.js. - Create
server/chat.jsand add the following code: - Update
server/index.jsto include our new chat functionality, replacing the code we wrote in part 1 of this series.
let socketio = undefined;
const chatHistory = [];
const userList = [];
const userTypingStatus = {};
const timers = {};
const typingStatusTime = 2000;
const id = {
message: 0,
user: 0,
unique: {}
};
Firstly, we create a variable socketio to use as a reference to our main Socket.IO object, and arrays to contain our chat messages and list of users. Next, we add some properties to keep track of the User is typing notifications. We also create an object to keep track of the IDs that we’ll attach to messages and users.
const initialise = (io) => {
socketio = io;
}
Next we create an initialise function which we can use to set our socketio reference.
const run = (socket) => {
// new socket connected, send user list and chat history
socket.emit('userList', userList);
socket.emit('chatHistory', chatHistory);
handleUserConnected(socket);
handleChatMessage(socket);
handleTypingStatus(socket);
}
The run function will handle the main functionality of the chat room for each individual socket which is passed to the function as an argument. The function will be called only once per socket when a new connection is made, so we will take advantage of this to send the current state of the app to the socket by emitting the events userList and chatHistory which both pass the corresponding information.
const handleUserConnected = (socket) => {
// listen for 'userConnected' events
socket.on('userConnected', (username) => {
// create a new user object
let newUser = {
userID: getID('user'),
uniqueID: getID('unique', username),
name: username
};
// determine user unique name
newUser.uniqueName =
newUser.uniqueID > 0 ?
`${newUser.name}#${newUser.uniqueID}` :
newUser.name;
// add the user to the userList
userList.push(newUser);
// send a login event to this user
socket.emit('userLogin', newUser);
// create a new message about the connection
let connectedMessage = {
sender: 'Server',
text: `${newUser.uniqueName} connected`,
id: getID('message'),
time: Date.now()
};
// add the message to the chatHistory
chatHistory.push(connectedMessage);
// send the user connection to all connected sockets
socketio.emit('userConnected', newUser);
socketio.emit('chatMessage', connectedMessage);
});
}
The handleUserConnected function listens for the userConnected event, and will respond appropriately.
First, we create a new user object and determine what the unique version of the username would be. For example, if two users named Rob connected, one user would keep the username Rob while the other would change to Rob#1.
Once the user is created, we store it in the userList then send an event notifying the user that they have now logged in. This event also sends the user object, so that the client knows which user it is, and has all the relevant information.
Next, we create a chat message that will notify all users of the new connection and update the clients with this new information.
You may have noticed that in this code snippet events were emitted using the socketio object, while in the previous snippet emitted events used the socket object. It is important to note the difference—socket.emit() is sending an event to that specific socket, whereas socketio.emit() is sending an event to all connected sockets, much like a broadcast.
const handleChatMessage = (socket) => {
// listen for 'chatMessage' events
socket.on('chatMessage', (chatMessage) => {
// attach a message id and timestamp
chatMessage.id = getID('message');
chatMessage.time = Date.now();
// add the message to the chat history
chatHistory.push(chatMessage);
// clear typing status for this user
clearTypingStatus(chatMessage.sender.userID);
// send the message to all connected sockets
socketio.emit('chatMessage', chatMessage);
});
}
The handleChatMessage function listens for the chatMessage event, and will respond appropriately. We receive a chat message from the client, add an ID and timestamp, then save it to our chat history. Then we ensure that we clear the typing status so that clients stop seeing this user as typing a message. Lastly, update all clients with the new message.
const handleTypingStatus = (socket) => {
// listen for 'setTypingStatus' events
socket.on('setTypingStatus', (typingStatus) => {
// set typing status to true for this user
userTypingStatus[typingStatus.user.userID] = {
user: typingStatus.user,
typing: true
};
// set a timer to reset the typing status
setStatusTimer(typingStatus.user.userID);
// broadcast the typing status'
socketio.emit('typingStatus', userTypingStatus);
});
}
The handleTypingStatus function listens for the setTypingStatus event, and will respond appropriately. We receive an event from the client, then set the typing status of the user to true and store that within an object using the user id as a key.
Next we start a timer that will revert the typing status back to false after a certain amount of time. Lastly, we broadcast the current status of all our users, including the newly updated one.
const clearTypingStatus = (userID) => {
// set typing status to false for this user
userTypingStatus[userID].typing = false;
// if a timer exists for this user, remove it
removeStatusTimer(userID);
// broadcast the typing status'
socketio.emit('typingStatus', userTypingStatus);
}
const removeStatusTimer = (userID) => {
// if this user id is a key of timers, clear the timer
if (timers.hasOwnProperty(userID)) {
clearTimeout(timers[userID]);
}
}
const setStatusTimer = (userID) => {
// if a timer exists for this user, remove it
removeStatusTimer(userID);
// set a timer to clear the typing status
timers[userID] = setTimeout(() => {
userTypingStatus[userID].typing = false;
// broadcast the typing status'
socketio.emit('typingStatus', userTypingStatus);
}, typingStatusTime);
}
These three functions assist in managing the typing status and associated timers of each user. The function clearTypingStatus sets the status to false and removes the timer immediately, whereas the function setStatusTimer sets the status to false after typingStatusTime time has elapsed.
const getID = (type, username = undefined) => {
let newID;
if (username) {
if (!id[type].hasOwnProperty(username)) {
// this is a new username
newID = id[type][username] = 0;
} else {
// this is a duplicate username
newID = id[type][username];
}
id[type][username] += 1;
} else {
// return the next id
newID = id[type];
id[type] += 1;
}
return newID;
}
// export these functions for external use
module.exports = { initialise, run };
The getID function manages the distribution of IDs for our messages and users. Finally, we export the two functions that we want to access from our main server/index.js file.
const express = require('express');
const http = require('http').Server(express);
const socketio = require('socket.io')(http, { pingTimeout: 60000 });
const chat = require('./chat');
const port = 3030;
chat.initialise(socketio);
socketio.on('connection', (socket) => {
// new socket connected
chat.run(socket);
});
http.listen(port, () => {
console.log('Server started on port', port);
});
After including the chat.js file we created, we pass the socketio instance to the initialise function. Then, when the connection event triggers we pass the new socket connection through to the run function.
Update the Client Functionality
Now that the server has been updated to handle our chat component, we need to update the client to store our chat data and respond to server events.
- Create
src/store/chat.jsand add the following code. - Open
src/store/index.jsand edit the code until you have the following. - Create
src/mixins/addEventListener.jsand add the following code.
This is the Vuex store for our chat room component. The store is a managed collection of data that we use as the middleman between the app front-end and the server. The front-end tells the store to send an event through the socket connection. When the app receives an event from the server through the socket connection, the store is updated with the new data. The app front-end only updates when there is new data in the store.
import Vue from 'vue';
export default {
strict: false,
namespaced: true,
state: () => ({
users: [],
history: []
}),
state is the data that the store is holding. In this case we have users and history being kept in the store.
getters: {
GET_USERS: (state) => {
return state.users;
},
GET_HISTORY: (state) => {
return state.history;
}
},
The front-end uses getters to retrieve data from the store.
mutations: {
SET_USERS(state, users) {
state.users = users;
},
ADD_USER(state, user) {
Vue.set(state.users, user.userID, user);
},
SET_HISTORY(state, history) {
state.history = history;
},
ADD_MESSAGE(state, message) {
Vue.set(state.history, message.id, message);
}
},
mutations are used to change the data in the store somehow. For example, we can set an object collection entirely using SET_HISTORY, or add a new element to a collection with ADD_USER.
actions: {
RECEIVE_USERS({ commit }, users) {
commit('SET_USERS', users);
},
RECEIVE_USER({ commit }, user) {
commit('ADD_USER', user);
},
RECEIVE_HISTORY({ commit }, history) {
commit('SET_HISTORY', history);
},
RECEIVE_MESSAGE({ commit }, message) {
commit('ADD_MESSAGE', message);
}
}
};
actions are functions that can be called from the app front-end to perform a task, such as sending an event or receiving data. In our case, we would be calling an action from the front-end which in turn mutates our data in some fashion.
import Vue from 'vue';
import Vuex from 'vuex';
import * as socketio from '../plugins/socketio';
import chat from './chat';
Vue.use(Vuex);
export default new Vuex.Store({
strict: false,
actions: {
SEND_EVENT({}, event) {
socketio.sendEvent(event);
}
},
modules: {
chat
}
});
Loading our new file into the store as a module allows us to maintain good object separation and the usage of namespaces to access particular parts of the store. We also added a SEND_EVENT action in this file, as all of our future components will need to send information to the server.
import * as socketio from '../plugins/socketio';
export const addEventListener = {
methods: {
addEventListener(eventType, eventCallback) {
socketio.addEventListener({
type: eventType,
callback: eventCallback
});
}
}
};
Mixins are essentially a reusable code snippet that can be loaded into any of our future components without the need to rewrite the code. This mixin defines a function addEventListener which allows our components to specify which server events they will listen for, and how to react.
Create Chat Components
That takes care of the app-specific functionality. Now we can jump into building the front-end for our chat room. How should we tackle this? First let’s lay out the structure that we are aiming for. We’ll make it so that the app asks you to connect with a username before you begin, and once you are connected, you’ll see the chat room with components in this order:
- User Login
- Chat log
- Messages
- Typing notifications
- Send a message
- Text field
- Send button
- List of connected users
- User chips
User Login
When a user first loads the app, they’ll be confronted with a simple login field for them to select a username to enter the chatroom with.
- Create
src/components/ChatLogin.vueand add the following code:
<template>
<v-card-text>
<v-text-field
v-model="username"
placeholder="Enter a username"
@keydown.enter="setUsername()"
></v-text-field>
<v-btn block @click="setUsername()">
Connect
</v-btn>
</v-card-text>
</template>
<script>
export default {
name: 'ChatLogin',
data: () => ({
username: ''
}),
methods: {
setUsername() {
this.$emit('set-username', this.username);
}
}
}
</script>
This component contains a text field which takes the desired username, and handles emitting the data to the parent component.
New Dependencies
We can’t have a really nice chat room without some scrolling functionality. I think it would be ideal for the chat log to automatically scroll down when a new message arrives, but it would be annoying if you were manually scrolling back up to read an old message when that happened.
Well, luckily we can save time and use an existing library to handle this for us! A bit of searching online found this nice little package called VueChatScroll.
- Install VueChatScroll:
- Import into
src/main.js:
npm install --save vue-chat-scroll
import Vue from 'vue';
import App from './App.vue';
import store from './store';
import vuetify from './plugins/vuetify';
import moment from 'moment';
import VueChatScroll from 'vue-chat-scroll';
Vue.use(VueChatScroll);
Vue.config.productionTip = false;
Vue.prototype.moment = moment;
new Vue({
store,
vuetify,
render: (h) => h(App)
}).$mount('#app');
Chat Log
The chat log will consist of messages and user typing notifications. It will be easiest if we work from the bottom up, so let’s start with individual messages. The easiest way to create a nice looking chat message is probably to use the v-alert component from Vuetify as a base and change up some settings.
- Create
src/components/ChatMessage.vueand add the following code. - Create
src/components/ChatLog.vueand add the following code.
<template>
<v-alert
class="my-1"
dense
outlined
text
max-width="80%"
:icon="false"
:border="getSetting('border')"
:class="getSetting('align')"
:type="getSetting('type')"
>
<div v-if="message.showSender" class="overline">
<b>{{ message.sender.uniqueName }}</b>
</div>
<div class="black--text">
{{ message.text }}
</div>
<div class="text-right time">
{{ formatTime(message.time) }}
</div>
</v-alert>
</template>
First up, we’ll create a template that uses the v-alert component. We’ll get some easy wins by setting the props to style the message component. The message will contain text and a timestamp, and show the sender name in some circumstances.
<script>
export default {
name: 'ChatMessage',
props: {
message: {
type: Object,
required: true
}
},
data: () => ({
messageSettings: {
received: {
align: 'align-self-start',
border: 'left',
type: 'error'
},
sent: {
align: 'align-self-end',
border: 'right',
type: 'info'
},
server: {
align: 'align-self-center',
border: 'left',
type: 'warning'
}
}
}),
The message component takes a message object as a prop, and can have three types – sent, received, and server messages.
methods: {
getSetting(setting) {
return this.messageSettings[this.message.type][setting];
},
formatTime(time) {
if (this.moment(time).isSame(this.moment(), 'day')) {
return this.moment(time).format('h:mm a');
} else {
return this.moment(time).format('D MMMM, h:mm a');
}
}
}
}
</script>
<style scoped>
.time {
font-size: 10px;
line-height: 10px;
}
</style>
The getSetting method handles retrieving the appropriate setting based on the message type, and the formatTime method provides a nicely formatted timestamp.
<template>
<v-card-text>
<v-card>
<div
v-chat-scroll="scrollSettings"
class="d-flex flex-column pa-4 overflow-y-auto log"
ref="log"
:style="scrollHeight"
@scroll="onScroll"
>
<chat-message
v-for="message of messagesToDisplay"
:key="message.id"
:message="message"
/>
<v-fab-transition>
<v-btn
v-if="showScrollButton"
absolute
class="scroll"
color="primary"
fab
x-small
@click="scrollToBottom()"
>
<v-icon>keyboard_arrow_down</v-icon>
</v-btn>
</v-fab-transition>
</div>
<v-expand-transition v-if="hasTypingStatus">
<div class="px-4 py-1 primary white--text caption">
{{ formattedTypingStatus }}
</div>
</v-expand-transition>
</v-card>
</v-card-text>
</template>
The ChatLog component handles the display of all chat messages. The v-chat-scroll property of the div element enables the scroll functionality provided by the VueChatScroll package we installed earlier. There are also two conditional elements that may be rendered—a button which will scroll back to the bottom of the chat log, and the notifications for when another user is typing a message.
<script>
import ChatMessage from './ChatMessage';
export default {
name: 'ChatLog',
props: {
messages: {
type: Array,
required: true
},
username: {
type: String,
required: true
},
typingStatus: {
type: Object,
required: true
}
},
components: {
ChatMessage
},
data: () => ({
scrollSettings: {
always: false,
smooth: true
},
scrollDistance: 0,
logHeight: 400,
hasScrolled: false,
showScrollButton: false
}),
This component takes in three props, an array of messages, the username of this user, and an object containing data on which other users are currently typing.
computed: {
hasTypingStatus() {
return this.formattedTypingStatus !== '';
},
messagesToDisplay() {
let lastSender = '';
let messages = [];
for (let message of this.messages) {
message.type = this.getType(message);
message.showSender =
lastSender !== message.sender.uniqueName &&
message.type === 'received';
messages.push(message);
lastSender = message.sender.uniqueName;
}
return messages;
},
scrollHeight() {
return `max-height: ${this.logHeight}px;`;
},
formattedTypingStatus() {
const users =
Object.values(this.typingStatus).filter((status) => {
return status.user.uniqueName !==
this.username && status.typing
}).map(status => status.user.uniqueName);
if (users.length === 0) {
return '';
}
const userString =
users.join(', ').replace(/,([^,]*)$/, ' and $1');
const userPlural = users.length > 1 ? 'are' : 'is';
return `${userString} ${userPlural} typing...`
}
},
There are several computed properties to help calculate variables used in the template. messagesToDisplay is used to build the array of messages that is rendered by the component. At the same time it also determines if the sender of the message is the first in a series, so that we know when to show the username on the messages.
watch: {
messages(value) {
if (this.hasScrolled) {
this.showScrollButton = true;
}
},
scrollDistance(value) {
if (value === 0) {
this.hasScrolled = false;
this.showScrollButton = false;
}
}
},
There are two properties being watched. When the value of messages changes we check to see if the user has scrolled away from the bottom, and if they have we show the button that allows them to scroll back to the bottom. The other watched property scrollDistance does the opposite, and checks to see when the scrollDistance is 0 (at the bottom) and then hides the scroll button again.
methods: {
getType(message) {
return message.sender === 'Server' ?
'server' :
message.sender.uniqueName === this.username ?
'sent' :
'received';
},
onScroll(event) {
this.hasScrolled = true;
this.scrollDistance =
event.target.scrollHeight -
event.target.scrollTop -
this.logHeight;
},
scrollToBottom() {
this.$refs.log.scrollTop = this.$refs.log.scrollHeight;
}
}
}
</script>
There are three methods in the ChatLog component, one to determine the type of the message, and two involving the scrolling behaviour. onScroll triggers when the user scrolls the view, and calculates how far the log has scrolled from the bottom.
<style scoped>
.log {
scroll-behavior: smooth;
}
.scroll {
right: 16px;
}
</style>
Lastly, we’ll add a small amount of styling. The scroll-behavior css property will ensure the manual scrolling we added is smooth.
Send a Message
The messaging component of the app is fairly straightforward, with a simple v-textarea component.
- Create
src/components/ChatSend.vueand add the following code:
<template>
<v-card-text>
<v-textarea
v-model="newMessage"
clearable
placeholder="Send chat message"
rows="3"
:autofocus="true"
:no-resize="true"
:solo="true"
@keydown.enter="sendMessage()"
@keydown.enter.prevent
@keydown="textChanged()"
></v-textarea>
<v-btn block color="primary" @click="sendMessage()">
Send
</v-btn>
</v-card-text>
</template>
<script>
export default {
name: 'ChatSend',
data: () => ({
newMessage: ''
}),
methods: {
sendMessage() {
if (this.newMessage !== '') {
this.$emit('send', this.newMessage);
this.newMessage = '';
}
},
textChanged() {
this.$emit('is-typing');
}
}
}
</script>
First we create the template with a text area component and button, then handle the send and is-typing message events.
List of Connected Users
Once the number of users increases a simple text list would become insufficient, so let’s use a v-chip to display each user and help keep things easier to read.
- Create
src/components/ChatUserChip.vueand add the following code: - Create
src/components/ChatUsers.vueand add the following code:
<template>
<v-chip
class="mr-2 mb-2"
:color="chipSettings[user.type].chipColour"
>
<v-avatar left>
<v-icon :color="chipSettings[user.type].iconColour">
{{ chipSettings[user.type].icon }}
</v-icon>
</v-avatar>
{{ user.uniqueName }}
</v-chip>
</template>
<script>
export default {
name: 'ChatUserChip',
props: {
user: {
type: Object,
required: true
}
},
data: () => ({
chipSettings: {
currentUser: {
chipColour: 'primary',
icon: 'person',
iconColour: 'white'
},
otherUser: {
chipColour: 'default',
icon: 'person',
iconColour: 'error'
}
}
})
}
</script>
First, create the template—a chip with a username and icon with colour determined by chipSettings. The chip takes one prop, a user object which contains relevant data.
<template>
<div>
<v-card-subtitle>Connected Users</v-card-subtitle>
<v-card-text>
<chat-user-chip
v-for="(user, i) of usersToDisplay"
:key="i"
:user="user"
/>
</v-card-text>
</div>
</template>
<script>
import ChatUserChip from './ChatUserChip';
export default {
name: 'ChatUsers',
props: {
users: {
type: Array,
required: true
},
username: {
type: String,
required: true
}
},
components: {
ChatUserChip
},
computed: {
usersToDisplay() {
let users = [];
for (let user of this.users) {
user.type = this.username === user.uniqueName ?
'currentUser' :
'otherUser';
users.push(user);
}
return users;
}
}
}
</script>
The template will display a chip for each user in the usersToDisplay computed property. The same computed property will also determine if the user is the current user, or one of the other users, which will change the way the chip is rendered.
Bringing It All Together
Now we’ve built all of the components that we need to make the chat log work, let’s put the pieces together to create the final component.
- Create
src/components/Chat.vueand add the following code. - Update
src/components/RealTimeDemo.vuewith the following code.
<template>
<v-card class="elevation-0 grey lighten-4">
<div v-if="!hasUser">
<v-card-title>Connect to chat</v-card-title>
<chat-login @set-username="setUsername" />
</div>
<div v-else class="elevation-1 px-4">
<v-card-title>Connected as {{ user.uniqueName }}</v-card-title>
<chat-log
:messages="history"
:typing-status="typingStatus"
:username="user.uniqueName"
/>
<chat-send @is-typing="setTypingStatus" @send="sendMessage" />
<chat-users :users="users" :username="user.uniqueName" />
</div>
</v-card>
</template>
This template first checks to see if the user exists, and if not will display the ChatLogin component. If the user is logged in, we’ll display their username and then show the ChatLog, ChatSend, and ChatUsers components.
<script>
import { mapGetters } from 'vuex';
import { addEventListener } from '../mixins/addEventListener';
import ChatLog from './ChatLog';
import ChatLogin from './ChatLogin';
import ChatSend from './ChatSend';
import ChatUsers from './ChatUsers';
export default {
name: 'Chat',
mixins: [addEventListener],
components: {
ChatLog,
ChatLogin,
ChatSend,
ChatUsers
},
data: () => ({
user: undefined,
typingStatus: {}
}),
The script section first imports the getters that we created in the vuex store, as well as the mixin we created in the last last part of this blog series, and the chat components we just created.
computed: {
...mapGetters({
CHAT_USERS: 'chat/GET_USERS',
CHAT_HISTORY: 'chat/GET_HISTORY'
}),
users() {
return this.CHAT_USERS ? this.CHAT_USERS : [];
},
history() {
return this.CHAT_HISTORY ? this.CHAT_HISTORY : [];
},
hasUser() {
return !!this.user;
}
},
The computed ...mapGetters property uses spread syntax as a shorthand for retrieving all of our store data for local use. users and history ensure that missing data is handled appropriately, and provides a way to safely access data.
mounted() {
this.addEventListener('userList', (users) => {
this.$store.dispatch('chat/RECEIVE_USERS', users);
});
this.addEventListener('userConnected', (user) => {
this.$store.dispatch('chat/RECEIVE_USER', user);
});
this.addEventListener('userLogin', (user) => {
this.user = user;
});
this.addEventListener('chatHistory', (history) => {
this.$store.dispatch('chat/RECEIVE_HISTORY', history);
});
this.addEventListener('chatMessage', (message) => {
this.$store.dispatch('chat/RECEIVE_MESSAGE', message);
});
this.addEventListener('typingStatus', (typingStatus) => {
this.typingStatus = typingStatus;
});
},
Here we add event listeners for the server events that we set up earlier. As you can see, some of them end up having their data sent to the store to update our data, and others are used locally.
methods: {
setUsername(username) {
this.$store.dispatch('SEND_EVENT', {
type: 'userConnected',
data: username
});
},
sendMessage(message) {
this.$store.dispatch('SEND_EVENT', {
type: 'chatMessage',
data: {
sender: this.user,
text: message
}
});
},
setTypingStatus() {
this.$store.dispatch('SEND_EVENT', {
type: 'setTypingStatus',
data: {
user: this.user
}
});
}
}
}
</script>
Lastly, add some methods to handle sending messages to the server.
<template>
<v-container>
<v-row>
<v-col :cols="4">
<chat />
</v-col>
<v-col :cols="8">
</v-col>
</v-row>
</v-container>
</template>
<script>
import Chat from './Chat';
export default {
name: 'RealTimeDemo',
components: {
Chat
}
}
</script>
Finally, we can add our newly created Chat component to the RealTimeDemo component.
We can now test our new functionality. Start the server with the command node server, and open the app in your browser. Enter a username and send some chat messages. Open the app multiple times in different browser tabs to see how it handles multiple connections.
Stop the server by pressing the keys ctrl+c.
You can find this project in its entirety on GitHub.
Header image courtesy of NASA on Unsplash