Johnny Tordgeman
Posted on February 15, 2022
Cover Photo by Mike Winkler on Unsplash
Let me start by saying I’m a HUGE fan of Yew. Using the power and flexibility of Rust to build front-end components is something that I feel will only get bigger as the adaptation of WebAssembly grows. Recently, one of my side projects needed some WebSockets love. I figured this would be the perfect time to dive into Yew’s recently released 0.19 version, which introduced several significant changes to the framework (the removal of web-sys and its supported services (i.e. ConsoleService) and the introduction of Functional Components, to name a few).
As you might have guessed from the title already, we will build a chat app (such a cliche, I know, I’ll show myself out) using Yew, yew-router, yew-agent, and several other crates.
At least it’s got GIF support!
To give you a sense of what we are going to build, check out this lovely image:
🛑 Before moving forward make sure you have Rust and NodeJS installed.
🗿 The WebSocket Server
To use WebSockets on the client-side you need a server capable of working with WebSockets (a shocker, I know). To keep this post a (somewhat) reasonable length, and for the sake of focusing on a single subject, we won’t be discussing the WebSockets server too much. In a nutshell: other than handling the incoming and outgoing connections, the server saves the incoming connections in an array called users. Every few seconds the server will compare the users array to the server’s current active connection list to verify that all the users in the list are in fact still connected.
While you can go and build/use your own WebSockets server for our lovely chat app, the easier solution here will be to clone the NodeJS WebSocket server I used at https://github.com/jtordgeman/SimpleWebsocketServer. Once cloned, run npm i and then npm start and if all went well, you should now have a cute WebSockets server running locally on port 8080.
Time to Take Off 🚀
- Clone the starter project at https://github.com/jtordgeman/YewChat
- Install the toolchain dependencies
npm i
The starter project is nothing but an empty project with wasm-pack and webpack already set up, so we can focus our attention to Yew
🛤 ️Routing
Our app has three possible routes:
- login— a simple page with a textbox and a button for a user to enter their nickname
- chat — The main page — has a list of users, the chat display, and a textbox to write stuff to the chat
- 404 — If anyone tries to go to a page that doesn't exist — this catch-all page will show up in all its glory
If you are coming from React you probably know (and use) react-router-dom. Well, good news! yew-router is pretty much the exact same idea! yew-router handles the display of different pages (components) depending on the URL.
So enough with the theory, let’s get busy:
- Create a new folder named components under the src folder. We will host all of our components here.
- Create three files under the newly created components folder: chat.rs , login.rs , mod.rs. We will leave chat and login empty for now, in mod.rs add the following:
- At the top of lib.rs , add the following use statements:
Note that Login and Chat will show as unresolved. We will fix that when we start dealing with components later on.
- Thanks to yew-router we can use an enum to define our routes. We will add it to our lib.rs as follows:
Pretty neat stuff! We annotate each entry with the URL it handles. Note that the 404 route uses an additional annotation: #[not_found] — this macro comes from the yew-router package and is basically what makes this route a catch-all route.
- Next we need a way to translate the enum value of a route to an actual component. Add the following switch function just below the Route enum:
- To render our router (and in turn, the components it routes to) we will use a functional component. But what is a functional component you may ask? A functional component is a simplified version of the regular Yew component which can receive properties and determines the rendered content by returning HTML. In short — functional components are basically components that have been reduced to have only the view method.
- Last but not least, we need an entry point to our app — a function that the JavaScript can call in order to initiate our Yew app. Add the run_app function to lib.rs as follows:
🔬So what do we have here?
- Line 1 : Using the wasm_bindgen macro we expose the run_app function to JavaScript.
- Line 3 : We initialize the wasm_logger crate. Using wasm_logger and the log crates we can write debug logs to the browser’s console.
- Line 4 : We are passing our Main component (defined in the previous step) to the start_app method of yew, to — as you might have guessed — start the app.
With the routing in place, let’s move on to creating our components.
🪅 ️Components — Phase 1
For simplicity sake, our app will consist of two components:
- Login — a simple functional component to get the username and connect to the WebSockets server.
- Chat — the main component of the app — shows the chat window, and a text area to write content to the chat.
When users browse to the Login component they will type a username and click connect — that username then needs to pass down to the Chat component and be used there to connect to the WebSocket server. To achieve that we will use a Context (think of it as a global state of the app) that will hold the username for any component to use. The way we define a Context is as follows:
- Define a struct for the context and a type alias for it:
- In the main component, define a context using the use_state hook:
- Lastly, let’s use the context by wrapping the main component HTML with a ContextProvider element which enables the child elements to actually use this context.
A ContextProvider element requires a context struct (User in our case) and a context object (ctx), that is set using the element’s context property.
👁️ Note: the child elements of a ContextProvider will re-render whenever the context changes.
With the context in place, let’s build the Login component!
- In components/login.rs add the login functional component:
- Declare a use_state hook that will manage the state of the username the user typed in. When declaring a use_state hook we provide the default value (an empty String in our case), similarly to React’s use_state hook:
- To get a reference to the context (which as you might remember will hold the global state for the username), use the use_context hook:
- The Login component UI will contain an input element and a button. Whenever the input changes we need to update the local username variable. To connect between the UI element and username, we use a callback:
🔬So what do we have here?
- Line 7 : We clone the username state handler so we can later update it using its set method.
- Line 9 : We create a Callback from the input element’s onchange event.
- Line 10 : We get the target element (the element that fired the event) using the InputEvent’s target_unchecked_into method.
- Line 11 : We update the username state with the new value that the input element currently holds using the element’s value method.
🎉 If you have ever done any JavaScript work with HTML elements then all of this should look pretty similar.
- The next callback we need to create is for the submit button. Once the username is typed, and the user clicks on the submit button — we need to save that username to the global context so that other components can use it if needed. Create the onclick callback as follows:
- Lastly, let’s add the actual UI for this component using the html! macro:
👀 A few callouts:
- The input element registers to the oninput callback. Every time the input changes — we call oninput and update the username state.
- We use the Link element from the yew_router crate to route the browser to the Chat route using the to property.
- The Link element contains a button element, which registers to the onclick callback which updates the global context with the value of username. In addition to that, the button also sets the disabled property to prevent the user from clicking it, unless username has a length of 2 characters or more.
And that’s a wrap for the Login component 🎊. We now have a component that uses a Context in order to share its data (username in our case) with other components.
Now we may be tempted to run ahead and create the chat component, but let’s stop for a minute and think about how the chat component even works…
👋 Hello WebSockets!
At the heart of our chat application are WebSockets. WebSockets enable us to asynchronously send and receive messages between the server and the client without the need for constant polling from the client. This feature makes Websockets the perfect centerpiece for our chat app. Let’s go ahead and create a WebSocket service that will handle all the aspects of working with WebSockets in our app!
- Create a new folder called services under the src folder.
- Add a new file called mod.rs inside services with the following content:
- Our Websocket service will handle the following:
- Listen to incoming messages from the WebSocket server.
- Write messages to the WebSocket server.
- Communicate with other components using MPSC (Multi Producers Single Consumer) Channel (more on this soon 🤔)
Add a new file called websocket.rs inside the services folder with the following content:
😱 OMG there’s a lot going on here, let’s bite-size this whole thing:
- Lines 6–8: Creates the WebsocketService struct that holds a single property of type Sender. Sender allows us to asynchronously send messages on the channel that the receiver will later receive, in the order they are sent. Sender is cloneable, which means that every component that uses the service can clone it and use it to send messages back to the receiver, which as the name suggests — there can only be a single one.
- Lines 11–46 : The new function initialize the service, similar to a constructor in other languages. The function does the following:
- Connects to the WebSocket server (line 12)
- Create the MPSC channel (line 16)
- Spawns a new future (async task) on the current thread that will listen to the receiving end of the MPSC channel and write the received message to the WebSocket server. This is how components communicate with our service — by sending messages across the channel (lines 18–23).
- Spawns another new future on the current thread to listen to the incoming messages from the WebSocket server and log them out(lines 25–43).
- Finally, the function returns an instance of WebSocketService (Self) that holds the channel transmitter (in_tx).
Here’s a handy diagram visualizing how everything connects:
Awesome, with the WebSocket service behind us, let’s proceed with creating the actual chat component!
🪅 ️Components — Phase 2
For our Chat component, we will use a regular (read not functional) component because we will make good use of its different lifecycle methods.
- Open up chat.rs and add the following:
👀 A few callouts:
- The Msg enum holds the possible messages (read actions) our component can receive. Chat has two basic functionalities: either it handles a message received from the WebSocket server (HandleMsg), or it submits a message to the server (SubmitMessage) — for example when a user types something into the chat.
- MessageData represents a chat message, containing who is it from and what the actual message is.
- The MsgTypes enum holds the different types of messages the component can send or receive.
- Lastly, WebSocketMessage represents what a message to the WebSocket server looks like: it has a type (message_type) and then either an array of strings (such as in the case of the chat users list) or a single string (such as the case of sending a chat message to the server).
- Next, let’s add the Chat component itself:
The Chat components hold the user list (users), the input typed to the text box (chat_input), a reference to the Websocket service (wss), and finally, an array of all the messages typed to the chat (messages).
- The first component lifecycle method we add is the create method. create initializes the component state and it’s ComponentLink:
👀 Quite a few things are happening here so let’s break them down:
- We get the user object (of type Rc) we saved in the Context (lines 4–7) and then clone its username field (line 9), which is the actual user name the user used to log in.
- We create a new WebSocket message to register the current client with the WebSocket server (lines 11–15). Next, we send it to the WebSocket server using the WebSocketService’s channel transmitter (tx) (lines 17–23). If everything goes well we log a message to the console (line 22).
- We finish the method off by sending a fresh instance of Chat (lines 25–30).
- Next, we add the view method, which is responsible for rendering the component:
That's one long method 😅 Most of the stuff here is just HTML and styling so we won't dive into that, but here are a few interesting pointers:
- On line 4 we create a callback of type MouseEvent to use when the submit button is clicked. The callback will send a message of type SubmitMessage whenever the button is clicked. This message will then get handled by the update lifecycle method.
- On line 10 we iterate over the users field by using map and collect to render a list of currently connected users (each user is rendered in its own div element).
- On line 33 we iterate over the messages field, similar to how we did it previously for users.
- We use the ref property on the input element (line 57) so we can later reference it (i.e. to read its value) outside of the view lifecycle method.
- We set the Submit button’s onclick property (line 58) to submit the callback defined on line 4.
- We are FINALLY ready to run the project for the first time and see what we achieved so far! Inside the project root folder run npm start. This will kick the process of compiling our app into WASM, optimizing it, building the HTML (with Webpack), and finally — running a dev server.
If all goes well you should be greeted with a login page where you can enter a username. Once done the main chat screen appears, let’s inspect the dev tools console:
Three lines pop out almost immediately:
- We successfully sent a WebSocket message from our chat component to the WebSocket server (first debug line)
- The WebSocket service sent the username to the WebSocket server
- The WebSocket server returned a message with a type of users containing an array with the names of the connected users.
Now that begs the question: if our WebSocket service received a message back with all the connected users — why don't we see that on our UI? Did our chat component even get that message at all?
Eagled-eyed readers might notice that when we previously discussed our WebSocket service. We only discussed how components send messages to the service, but nowhere did we mention how the service sends back messages to the components. Let’s take another dive into the rabbit hole that is our WebSocket service…
🔌 WebSockets — Phase 2
We are currently using an MPSC channel to communicate between components and the WebSocket service, but as the name implies — we cannot use the same approach for updating the components since we would need the opposite direction, i.e. multi consumers (components) single producer (service), which doesn't exist.
https://medium.com/media/644b0fe0703d066ae60bd7165831a174/href
We use Yew Agents! Agents can be used to “route messages between components independently of where they sit in the component hierarchy” (taken from Yew’s official docs). That means we can use an agent’s dispatcher to send a message from the WebSocket service down to any listening component. Let’s create an event bus service which we will use to send these messages with.
Create a new file called event_bus.rs under services and paste the following content to it:
https://medium.com/media/5d8876d1d684913d723182682dcef02e/href
🔬So what do we have here?
- Lines 10–13 : we create the EventBus struct which holds a link and a list of subscribers.
- Lines 15–47 : we implement the Agent trait for our EventBus. There are a bunch of methods we need to implement (connected, disconnected etc), but the one we care about the most is handle_input, which iterates over all of the subscribers and using link, sends them the content of the message.
Now let’s go back to our WebSocket service and use the new event bus:
-
Open websocket.rs under the services folder. Add the required use statements and the event_bus initializing. We are creating a dispatcher because we need a one-way communication channel between the service and the components:
https://medium.com/media/8ed2ab338cd5d31bbdf04811ba1758bc/href To send a message on the channel, all we have to do is use send:
https://medium.com/media/40bf2850055509e4dc735edd39e7aed5/href
You can see we are making use of the EventBusMsg struct we defined in event_bus.rs to send a String message down the channel (lines 9 and 15).
Lastly, we need to add the event bus to the Chat component.
-
Open chat.rs under the components folder, and add _producer to the Chat struct:
https://medium.com/media/35f9ec005dceb966bf32355d07e3360a/href Head over to the create lifecycle method of Chat and add the default value for _producer to the returning Self statement:
https://medium.com/media/04010ad3c68e103fd8c2ba33ba68b048/href
If you run the app again now you’ll notice nothing really changes. The reason for that is we never implemented the update lifecycle method, which is in charge of handling the different messages our component get (like HandleMsg or SubmitMessage). The method returns a boolean indicating whether the component should be re-rendered or not.
- Under the create method, add the following implementation of update:
https://medium.com/media/a758d26b80832373969f8b0a75d5a68d/href
🔬So what do we have here?
The method matches msg to one of the two possible messages the component can get: HandleMsg or SubmitMessage. If the message is of type HandleMsg we perform another match and check whether it is a message of type Users or of type Message. Messages of type Users get sent every time the connected user list changes (a user connect/disconnect) and upon receiving this message — we populate the users array with the name and avatar of each user that is connected. Messages of type Message get sent when a user posts a message to the chat, and pretty similar to how we handled users, we append the new message to the messages array. In both of these cases, we return true since we need the component to rerender itself with the new data.
In the case the message is of type SubmitMessage we grab the message text from the input HTML element (remember that ref property we set earlier?) and using the WebSocket service channel transmitter (tx) we send the message to the WebSocket server. In the case of SubmitMessage we return false as we don't need the component to rerender itself.
And with this change, our app is now ready for prime time!
This post was definitely on the longer side, but I hope you got to experience how it is like working with Yew when building a web app. You can find the full source code at https://github.com/jtordgeman/YewChat. Feel free to hit me up on Twitter or by leaving a comment here — I promise I'll answer nicely ;p
Last but not least, huge thanks to Sara Lumelsky for proofreading and fixing my stupid spelling mistakes! :)
Posted on February 15, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.