Context
While working on my last project, a spaced repetition learning app (play with the demo here), I stumbled upon Svelte, a component framework that runs at build time and compiles your code into optimized, vanilla JS. I played with the tutorial a bit and it seemed really fun. I also quickly discovered Sapper, an application framework for building Svelte apps. So, I decided the next project I would do would be something small to help me learn about Svelte / Sapper.
In addition to Svelte, I also wanted to test out Socket.IO, a library that enables real-time, bidirectional, event-based communication between a client and server. So, I thought a good way to combine the two would be to build a simple chat application. I also felt that something like this could not only be a great way to personally learn something about these technologies but also an example that could be used to teach others (eg in a workshop, meetup, course, etc). As such I will write a little about how I found the app building process below. For the impatient, you can go directly to the app's repo or play around with the demo.
Setting Up
Getting up and running was rather painless and really as simple as the Sapper docs imply. With the following sequence of commands I had a new Sapper project ready to be worked on:
npx degit "sveltejs/sapper-template#rollup" chat-app
cd chat-app
npm install
The next step was to install the Socket.IO essentials. This was easy enough with npm install --save socket.io
. However, one gotchya I encountered was that I couldn't get my index.svelte
to properly import the io
object from the standalone client build exposed by the server by default at /socket.io/socket.io.js
. This could be due to a race condition in how all resources are loaded. So, I instead opted to import the package node_modules
, but this resulted in a module not found error for bufferutil
.
Finally, I realized that if I wanted to go that route I also had to explicitly install the client package: npm install --save socket.io-client
. While this is not mentioned in the getting started docs, it is mentioned in the extended docs (doh!!!). Another alternative would be to try to load from the CDN, but I didn't try this. Once I installed the client package, I was able to import it into my index.svelte
and begin using the io
object!
Building out the server
The Socket.IO docs are great but they deal explicitly with Express. I like and know Express rather well, but I had never tried Polka before and since it comes with Sapper by default I thought it would be a perfect time to give it a try. Luckily, the Polka people (or person it seems?) also wrote an example (a chat application nonetheless!) specifically showcasing how to use it was Socket.IO; so, I was able to lean on it quite heavily for guidance.
Check it out here. It's really all you need to get going. So instead of going over it redundantly here, I will simply refer to relevant server code as I explain different parts of the client below.
Building out the client
The client simply consists of an index.svelte page and a Heading.svelte
component. I decided to abstract out the heading to its own component in case I wanted to use it on other pages or even in other projects. To achieve something like this with Svelte is very easy; for example, my Heading.svelte
looks like so:
<style>
[...CSS...]
</style>
<script>
export let text;
</script>
<div id="heading">{text}</div>
Then, in index.svelte
, it is utilized like so:
<script>
import Heading from '../components/Heading.svelte';
[...]
</script>
<body>
<div class="main">
<Heading text={'Chat App'} />
[...]
</body>
It should also be noted that you can set a default value for text
in Heading.svelte
by simply assigning it to a value rather than leaving it undefined.
The chat functionality itself is mainly handled by various Socket.IO socket.on
and socket.emit
(or socket.broadcast.emit
) calls that are fired in response to user actions. For example, to implement message sending, we first add an event listener to the app's send button
, which is achieved very easily with Svelte:
<form action="">
<input id="m" autocomplete="off" {placeholder} bind:value={message} />
<button on:click|preventDefault={handleSubmit}>Send</button>
</form>
Note also that we are binding the value of the message input
field to the variable message
. This will create a two-way binding and make sure whatever is typed into the field is automatically bound to our message variable defined in our script
block (via something like the usual let message = ''
).
When the user clicks the send button
then, our function handleSubmit
, also defined in our script
block, will be called. Within it, we first add it to a messages
array, which acts as our data structure for storing all messages of a given session and then we emit a 'message'
event:
messages = messages.concat(message);
socket.emit('message', message);
Then, on the server side, we have a listener that will listen for such events and react accordingly whenever it receives one. In our case, that simply means broadcasting the message to all users (except the user that sent it):
socket.on('message', function(message) {
socket.broadcast.emit('message', message);
})
And finally, back in our script
block of index.svelte
, we have another socket.on
listener waiting to receive such 'message'
events so it can add them to the messages
array and display them in the chat window:
socket.on('message', function(message) {
messages = messages.concat(message);
});
At this point, you're probably thinking: 'okay, that's nice, but how do we display the messages to the user?'. Again, Svelte makes this very easy. With the help of Svelte's #each
block functionality, we can simply create a list element via ul
and then populate it with an li
element for each message
in messages
:
<div id="chatWindow">
<ul id="messages">
{#each messages as message}
<li>{message}</li>
{/each}
</ul>
</div>
Cool, now each time a new message
is added to messages
, the chat window is updated and displays it instantly! It would be nice for the new message to fade in though, rather than suddenly appear. Time for some CSS or extra JavaScript right? Nope, Svelte to the rescue:
<li transition:fade>{message}</li>
Yup, that's really all that's needed to add a nice fade-in transition to each message. Further, at this point, that's all you really need to have a working chat client! I could have also stopped here but I wanted to add a few little extras, one of which was a message informing the user of how many users total were currently chatting.
This ended up being a little tricky because I wasn't sure where to store and update this value. Svelte itself implements an app-wide store; however, this proved to not be the correct solution, as I needed something that would be updated for every client every time a new user connected. The solution thus ended up being to store the value server-side, and then on every new 'connection'
event, update the value and send it back to the client, which would then update its local copy accordingly. First the server code:
[...]
let numUsers = 0;
io(server).on('connection', function(socket) {
++numUsers;
let message = 'Server: A new user has joined the chat';
socket.emit('user joined', { message, numUsers });
socket.broadcast.emit('user joined', { message, numUsers });
[...]
Note that we must call both socket.emit
(which sends a message back to the user who sent the event) and socket.broadcast.emit
(which sends a message to all users except the user who sent the event). This is because we want both the user who joined and all other users to receive this event and have the correct number of users locally stored.
Then, back on the client in our script
block, we set up our local variable to store the number of users connected and our listener for the 'user joined'
event:
let numUsersConnected = 0;
[...]
socket.on("user joined", function({message, numUsers}) {
messages = messages.concat(message);
numUsersConnected = numUsers;
updateScroll();
});
[...]
And finally, down in our html
we insert our data as such:
<p>There {numUsersConnected == 1 ? 'is' : 'are'} {numUsersConnected} {numUsersConnected == 1 ? 'user' : 'users'} currently chatting!</p>
You may have noticed in the listener above that we also make a call to a function called updateScroll
. This is simply a function that auto-scrolls the chat window on each new message:
function updateScroll() {
const chatWindow = document.getElementById('chatWindow');
chatWindow.scrollTop = chatWindow.scrollHeight;
}
The gotchya I encountered with this though was that every time this function would call, the chat window would auto-scroll, but not all the way to the bottom of the div
. Instead, it would scroll just above the last sent message, so the user would always have to scroll a little bit more to reveal the latest message. What could be causing this weird bug??
I finally realized that this was due to a little race condition. Essentially, the updateScroll function was being called before the new message was actually being added to the DOM, resulting in the scrollTop
always being set to just above the latest message! To fix this, I simply threw the scrollTop
assignment into a setTimeout
callback so it would be handled by the web API and be forced to wait in the job queue
until all other code had been executed:
function updateScroll() {
const chatWindow = document.getElementById('chatWindow');
setTimeout(() => {
chatWindow.scrollTop = chatWindow.scrollHeight;
}, 0);
}
And with that the scroll began working perfectly!
A final gotchya you may encounter when working with Svelte is that if you attempt to add an event listener to the window
object you will get a ReferenceError: window is not defined
upon execution. This is also due to a race condition; however, Svelte documents this and provides a simple way to interact with the window
object via a special svelte:window
element. In this app, the element was utilized to call a function whenever the window unloaded:
<svelte:window on:unload={emitUserDisconnect}
And just like that you not only have access to the window
object but can also easily set up event listeners!
Conclusion
This was a really quick and fun starter project to get familiar with Svelte / Sapper and Socket.IO. I think it would make for a great project to implement together with others in an afternoon knowledge-sharing session (eg a meetup). While there are definitely a few gotchyas, once you truly understand the problem you typically find that they are actually already documented and a clean solution exists. I suggest you give these technologies a try; both make developing rather painless and perhaps more importantly- very fun!
Top comments (2)
I believe instead of the setTimeout the official solution would have been
await tick()
(check svelte.dev/tutorial/tick). Otherwise, very cool and helpful article!Ooo thank you for letting me know about that! Indeed
setTimeout
felt rather hacky at the time, which should have been a clear indicator there was a better solution out there. I'm glad you found the article helpful tho! Hope you're making something cool with Svelte and the rest of the gang :)