In this tutorial, we are building a real-time chat app with Node.js/Express backend and React/Bootstrap frontend. We’re using Socket.io with Express to handle incoming socket connections and emit messages to connected clients. We’re using Webpack4 to transform and bundle our frontend assets. Let’s start by initializing a new project with npm init and add these packages to dependencies.
1 2 3 4 5 6 7 8 9 10 11 |
"dependencies": { "axios": "^0.18.0", "bootstrap": "^4.1.1", "express": "^4.15.2", "font-awesome": "^4.7.0", "jquery": "^3.3.1", "popper.js": "^1.14.3", "react": "^16.4.0", "react-dom": "^16.4.0", "socket.io": "^1.7.3" }, |
Also, add these packages to devDependencies.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
"devDependencies": { "babel-core": "^6.26.3", "babel-loader": "^7.1.4", "babel-preset-es2015": "^6.24.1", "babel-preset-react": "^6.24.1", "cross-env": "^5.1.6", "css-loader": "^0.28.11", "file-loader": "^1.1.11", "less-loader": "^4.1.0", "mini-css-extract-plugin": "^0.4.0", "node-sass": "^4.9.0", "postcss-loader": "^2.1.5", "sass-loader": "^7.0.1", "style-loader": "^0.21.0", "url-loader": "^1.0.1", "webpack": "^4.8.3", "webpack-cli": "^2.1.4" } |
Add these to your scripts
1 2 3 4 5 6 |
"scripts": { "start": "node index.js", "dev-server": "nodemon server.js", "prod": "cross-env NODE_ENV=production webpack --config webpack.config.js", "dev": "cross-env NODE_ENV=development webpack --config webpack.config.js" } |
We’re using nodemon to monitor changes in our server.js file and automatically restart our node server. You can install it globally by running
$ npm install -g nodemon
Setting up Socket.io Server
Now that we’ve installed dependencies and setup run scripts, let’s quickly dive into writing our application’s backend that will handle incoming HTTP requests and WebSocket connections. Create a server.js file in your project directory.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
let express = require('express'); let app = require('express')(); let server = require('http').Server(app); let io = require('socket.io')(server); let port = 8989; app.use('/assets', express.static(__dirname + '/dist')); app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); server.listen(port, () => { console.log('Running server on 127.0.0.1:' + port); }); |
We are using Socket.io in conjunction with Express. We’are passing our HTTP server to socket.io and listen to incoming HTTP requests and socket connections. Later on, we’ll setup Webpack to output bundled assets to dist directory which will be accessible through /assets route. Create an index.html file in our project directory. We’ve already configured our web server to serve it against / route.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <link rel="stylesheet" href="/assets/style.css"> <title>Chatter</title> </head> <body> <div id="root"></div> <script src="/assets/bundle.js"></script> </body> </html> |
This is how our index.html file looks like. We’re bootstrapping CSS and Javascript bundle and use #root to mount our React app.
Webpack Configuration
Now we’ll configure Webpack to output transformed bundles to dist directory. Since we’re using ES6, JSX and Sass, we need to pipe our code through a few loaders to transform them into browser ready CSS and JS code. We’re using css-loader and sass-loader to transform SASS to CSS and then extract to a dedicated file using MiniCssExtractPlugin. We’re using babel-loader to transform ES6 and JSX to Javascript. We’re also applying url-loader and file-loader to images and fonts to extract them to dist/fonts directory.
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
const path = require('path'); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const devMode = process.env.NODE_ENV !== 'production'; const webpack = require('webpack'); module.exports = { entry : './src/app.js', output : { filename : 'bundle.js', path : path.resolve(__dirname, 'dist') }, module : { rules : [ { test: /\.s?[ac]ss$/, use: [ MiniCssExtractPlugin.loader, { loader : 'css-loader', options: { sourceMap: true } }, { loader : 'sass-loader', options: { sourceMap: true } }, ], }, { test: /\.js$/, exclude: /node_modules/, use: "babel-loader" }, { test: /\.png$/, loader: 'url-loader?limit=100000' } , { test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?limit=10000&mimetype=application/font-woff&name=fonts/[name].[ext]" }, { test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?limit=10000&mimetype=application/font-woff&name=fonts/[name].[ext]" }, { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?limit=10000&mimetype=application/octet-stream&name=fonts/[name].[ext]" }, { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file-loader", options: { name: 'fonts/[name].[ext]', context: '' } }, { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?limit=10000&mimetype=image/svg+xml&name=fonts/[name].[ext]" } ] }, devtool: 'source-map', plugins: [ new MiniCssExtractPlugin({ filename: "style.css" }), new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery' }) ], }; |
React App
This is how our app will look like and function at the end of this tutorial.
We’ve configured Webpack to use src/App.js as an entry point. Let’s create it and write our first component. We’re rendering Navbar and Chat component in our App component.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import React from 'react'; import ReactDOM from 'react-dom'; import 'bootstrap'; import 'font-awesome/css/font-awesome.css'; import './app.scss'; import Navbar from './components/Navbar'; import Chat from './components/chat/Chat'; class App extends React.Component{ render(){ return( <React.Fragment> <Navbar/> <Chat/> </React.Fragment> ) } } ReactDOM.render( <App/>, document.getElementById('root') ); |
Navbar is a default Bootstrap4 navbar.
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 |
import React from 'react'; class Navbar extends React.Component { render() { return ( <nav className="navbar navbar-expand-lg navbar-dark fixed-top bg-dark"> <a className="navbar-brand" href="#">Chatter</a> <button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span className="navbar-toggler-icon"/> </button> <div className="collapse navbar-collapse" id="navbarSupportedContent"> <ul className="navbar-nav ml-auto"> <li className="nav-item float-right"> <a className="nav-link" target="_blank" href="https://github.com/waleedahmad">Github</a> </li> </ul> </div> </nav> ) } } export default Navbar; |
Now let’s create a Chat component.
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 |
import React from 'react'; import Users from "./Users"; import Messages from "./Messages"; import EnterChat from "./EnterChat"; import socketIOClient from 'socket.io-client'; class Chat extends React.Component { constructor(props){ super(props); this.socket = null; this.state = { username : localStorage.getItem('username') ? localStorage.getItem('username') : '', uid : localStorage.getItem('uid') ? localStorage.getItem('uid') : this.generateUID(), chat_ready : false, users : [], messages : [], message : '' } } render() { return ( <div className="chat"> {this.state.chat_ready ? ( <React.Fragment> <Users users={this.state.users}/> <Messages sendMessage={this.sendMessage.bind(this)} messages={this.state.messages} /> </React.Fragment> ) : ( <EnterChat setUsername={this.setUsername.bind(this)} /> )} </div> ) } } export default Chat; |
We’re relying on localStorage to store username and user ID. We’re generating a user ID and storing it in browser local storage.
1 2 3 4 5 6 7 8 9 |
generateUID(){ let text = ''; let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; for (let i = 0; i < 15; i++){ text += possible.charAt(Math.floor(Math.random() * possible.length)); } localStorage.setItem('uid', text); return text; } |
generateUID() method will generate a random string, use it to set uid key on local storage and return it. We’ll use it to identify users accessing chat app from different browser tabs. We’re also storing and retrieving username from local storage. If it doesn’t exist, we’ll ask user to enter a username to join chat room. We’ll render EnterChat component if chat_ready state is false. We’re initially setting it to false and later on updating it in initChat() method.
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 |
import React from 'react'; class EnterChat extends React.Component{ constructor(props){ super(props); this.state = { username : '' } } changeUsername(e){ e.preventDefault(); if(this.state.username.length){ this.props.setUsername(this.state.username); }else{ alert('Please provide a username'); } } onChange(e){ this.setState({ username : e.target.value }) } render(){ return( <div className="enter-chat d-flex justify-content-center align-items-center"> <form className="col-xs-12 col-sm-12 col-md-6 col-lg-4" onSubmit={this.changeUsername.bind(this)}> <React.Fragment> <div className="input-group "> <input className="form-control" placeholder="Username" value={this.state.username} onChange={this.onChange.bind(this)} /> <div className="input-group-append"> <button className="btn btn-outline-secondary" type="submit"> Join </button> </div> </div> </React.Fragment> </form> </div> ) } } export default EnterChat; |
We’ll render an input form in EnterChat component. onSubmit() it will update Chat component username state.
1 2 3 4 5 |
componentDidMount(){ if(this.state.username.length) { this.initChat(); } } |
In componentDidMount() method, we’re validating username state. If it exists, we’ll call initChat() method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
initChat(){ localStorage.setItem('username', this.state.username); this.setState({ chat_ready : true, }); this.socket = socketIOClient('ws://localhost:8989', { query : 'username='+this.state.username+'&uid='+this.state.uid }); this.socket.on('updateUsersList', function (users) { console.log(users); this.setState({ users : users }); }.bind(this)); this.socket.on('message', function (message) { this.setState({ messages : this.state.messages.concat([message]) }); this.scrollToBottom(); }.bind(this)); } |
1 2 3 4 5 6 7 |
setUsername(username, e){ this.setState({ username : username }, () => { this.initChat(); }); } |
setUsername() will update username state and call initChat() method. initChat() will initialize WebSockets and register event listeners. We’re also passing username and uid as query when initiating our WebSocket. On the backend, we’ll listen to incoming connections and retrieve username and uid from query. Update your server.js file and add this code.
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 |
let users = {}; getUsers = () => { return Object.keys(users).map(function(key){ return users[key].username }); }; io.on('connection', (socket) => { let query = socket.request._query, user = { username : query.username, uid : query.uid, socket_id : socket.id }; if(users[user.uid] !== undefined){ createSocket(user); socket.emit('updateUsersList', getUsers()); } else{ createUser(user); io.emit('updateUsersList', getUsers()); } socket.on('message', (data) => { socket.broadcast.emit('message', { username : data.username, message : data.message, uid : data.uid }); }); socket.on('disconnect', () => { removeSocket(socket.id); io.emit('updateUsersList', getUsers()); }); }); |
On a new connection, we’ll create a user with username, uid, and socket_id. If uid already exist on users object, we’ll add sockets to the existing user otherwise will create a new user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
createSocket = (user) => { let cur_user = users[user.uid], // update existing user socket updated_user = { [user.uid] : Object.assign(cur_user, {sockets : [...cur_user.sockets, user.socket_id]}) }; users = Object.assign(users, updated_user); }; createUser = (user) => { users = Object.assign({ // create a new user on users object with uid [user.uid] : { username : user.username, uid : user.uid, sockets : [user.socket_id] } }, users); }; |
On disconnect, we’ll retrieve disconnected socket id and pass it to removeSocket() method.
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 |
removeSocket = (socket_id) => { let uid = ''; Object.keys(users).map(function(key){ let sockets = users[key].sockets; if(sockets.indexOf(socket_id) !== -1){ uid = key; } }); let user = users[uid]; if(user.sockets.length > 1){ // Remove socket only let index = user.sockets.indexOf(socket_id); let updated_user = { [uid] : Object.assign(user, { sockets : user.sockets.slice(0,index).concat(user.sockets.slice(index+1)) }) }; users = Object.assign(users, updated_user); }else{ // Remove user by key let clone_users = Object.assign({}, users); delete clone_users[uid]; users = clone_users; } }; |
If a user has more than one connected socket, we’ll remove the disconnected socket. If it’s the only one, we’ll remove the user from users object. We’ll emit updateUsersList event on socket connect and disconnect events and listen to it on our frontend.
1 2 3 4 5 6 |
this.socket.on('updateUsersList', function (users) { console.log(users); this.setState({ users : users }); }.bind(this)); |
Now that we’ve set up our Chat component, let’s move onto Users and Messages component. We’re passing users as props to Users component.
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 |
class Users extends React.Component { constructor(props){ super(props); this.state = { users : props.users } } static getDerivedStateFromProps(nextProps, prevState){ return { users : nextProps.users, } } render() { return ( <div className="users col-xs-12 col-sm-12 col-md-4 col-lg-2"> {this.state.users.length ? this.state.users.map((user, i) => { return ( <div className="user" key={i}> <i className="fa fa-user"/> {user} </div> ) }) : 'No Users Online'} </div> ) } } |
When we’ll receive new users through sockets, updated user prop will be passed to our Users component and update state through getDerivedStateFromProps method.
Let’s move on to Messages component.
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 59 60 61 62 63 64 65 66 67 |
class Messages extends React.Component { constructor(props){ super(props); this.state = { height : 0, messages : props.messages, gif : false, } } static getDerivedStateFromProps(nextProps, prevState){ return { messages : nextProps.messages, } } componentDidMount(){ this.assignHeight(); window.addEventListener("resize",this.assignHeight.bind(this)); } assignHeight(){ let chat_height = this.state.gif ? 200 : 35; let _docHeight = (document.height !== undefined) ? document.height : document.body.offsetHeight; this.setState({ height : _docHeight - 65 - chat_height }); } componentWillUnmount(){ window.removeEventListener("resize", this.assignHeight.bind(this)); } toggleGif(e){ this.setState({ gif : !this.state.gif }, () => { this.assignHeight(); }); } render() { return ( <div className="messages col-xs-12 col-sm-12 col-md-8 col-lg-10" style={{height : this.state.height + 'px'}}> {this.state.messages.length ? ( this.state.messages.map((message, i) => { return ( <Message key={i} message={message}/> ) }) ) : <div className="no-message">No messages in chat room</div>} {this.state.gif ? ( <GifBox sendMessage={this.props.sendMessage} toggleGif={this.toggleGif.bind(this)} /> ) : ( <ChatBox sendMessage={this.props.sendMessage} toggleGif={this.toggleGif.bind(this)} /> )} </div> ) } } |
In our Messages component, we’re rendering messages passed from chat component. User can toggle between Gif and Text Chat input option. We’ve separate components for handling both types of input. We’re passing sendMessage prop that we received from Chat component to both our Input components. On invoke, it will emit message event to the socket server, which will broadcast message to all connected clients expect sender. We’re assigning a dynamic height to div.message to leave space for GifBox and Chatbox input components below messages.
ChatBox is a simple text input component where a user will enter a message and it will be broadcasted to all connected clients.
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 |
class ChatBox extends React.Component{ constructor(props){ super(props); this.state = { message : '' } } onChange(e){ this.setState({ message : e.target.value }) } onKeyUp(e){ if (e.key === 'Enter') { if(this.state.message.length){ this.props.sendMessage({ type : 'message', text : this.state.message }); this.setState({message : ''}); }else{ alert('Please enter a message'); } } } render(){ return ( <div className="input-group chatbox col-xs-12 col-sm-12 col-md-8 col-lg-10"> <div className="input-group-prepend"> <button className="btn btn-outline-secondary" type="button" onClick={this.props.toggleGif} > <i className="fa fa-image"/> GIF </button> </div> <input className="form-control" placeholder="Type message" value={this.state.message} onChange={this.onChange.bind(this)} onKeyUp={this.onKeyUp.bind(this)} /> </div> ); } } |
GifBox will search GIFs using Giphy API.
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
class GifBox extends React.Component{ constructor(props){ super(props); this.state = { query : '', GIFs : [], offset : 0, message : 'Input a query to search related gif results' } } onChange(e){ this.setState({ query : e.target.value }) } onKeyUp(e){ if (e.key === 'Enter') { if(this.state.query.length){ this.setState({ GIFs : [], offset : 0 }); this.getGIFs(); }else{ alert('Please enter a search term to find gif'); } } } registerScrollEvent(){ let $gifs = $('.gifs')[0]; $($gifs).on('scroll', function() { if($($gifs).scrollTop() + $($gifs).innerHeight() >= $($gifs).prop('scrollHeight')) { this.setState({ offset : this.state.offset + 10 }, () => { this.getGIFs(); }); } }.bind(this)); } removeScrollEvent(){ $('.gifs').off('scroll'); } getGIFs(){ this.removeScrollEvent(); this.setState({ message : '', }); axios.get('https://api.giphy.com/v1/gifs/search', { params: { api_key: 'YOUR_API_KEY', q : this.state.query, limit : 10, offset : this.state.offset } }) .then(function (response) { let results = response.data.data; if(results.length){ let gifs = results.map((gif) => { return { original : gif.images.original.url, fixed : gif.images.fixed_height.url }; }); this.setState({ GIFs : this.state.GIFs.concat(gifs) }, () => { this.registerScrollEvent(); }); }else{ this.setState({ GIFs : [], message : 'No GIFs found' }) } }.bind(this)) .catch(function (error) { console.log(error); }); } sendGIF(gif, e){ console.log(gif); this.props.sendMessage({ type : 'gif', url : gif.original }); } render(){ return ( <div className="gifbox col-xs-12 col-sm-12 col-md-8 col-lg-10"> <div className="input-group"> <div className="input-group-prepend"> <button className="btn btn-outline-secondary" type="button" onClick={this.props.toggleGif} > <i className="fa fa-comment"/> Messages </button> </div> <input className="form-control" placeholder="Search Gif" value={this.state.query} onChange={this.onChange.bind(this)} onKeyUp={this.onKeyUp.bind(this)} /> </div> <div className="gifs"> {this.state.GIFs.length ? this.state.GIFs.map((gif, i) => { return ( <div className="gif" key={i}> <i className="fa fa-share-square share" onClick={this.sendGIF.bind(this, gif)} /> <img src={gif.fixed} alt=""/> </div> ); }) : ( <div className="searching h-100 text-center py-5"> <span> {this.state.message} </span> </div> ) } </div> </div> ); } } |
In getGIFs() method, we’ll call Giphy API to fetch GIFs. Make sure you add your own API key to axios params. registerScrollEvent() method will register a scroll event that will call API again with a new offset when users scroll down to the bottom of the GIF results. We’ll remove scroll event by calling removeScrollEvent() when component unmounts. When a user clicks on the share button, it will emit a send message event with gif URL.
I’ve created a repository for you. Clone it and run.
1 2 3 |
npm install npm run dev npm run dev-server |
Open 127.0.0.1:8989 in your browser to use the app. If you’ve any issues or want to ask something, leave a comment and I’ll try to help you.
The chats are not being displayed, when I am using the localhost, please can you help me with this.