I know we all love JavaScript. We all love TypeScript, React, etc...
But sometimes don't you feel that you want to try something else? Something new?
I want to introduce a different way to build modern web apps using WebAssembly.
It will bring you a next level of frontend world. Mostly, I use Rust alongside WebAssembly because it enables me to utilize a strongly static type system and efficient memory management system that JavaScript and TypeScript lack of.
OK, enough, let's get to the point.
First of all, to make that easy, I recommend to install Rocal which is a full-stack WASM framework to build modern web apps powered by WebAssembly. Here is a command to install it on your MacOS or Linux!
$ curl -fsSL https://www.rocal.dev/install.sh | sh
if you get some trouble while you are installing it, join our Discord. I'll help you to build an app with Rocal step-by-step!
Discord
Welcome to Rocal
What's Rocal?
Rocal is Full-Stack WASM framework that can be used to build fast and robust web apps thanks to high performance of WebAssembly and Rust's typing system and smart memory management.
Rocal adopts MVC(Model-View-Controller) architecture, so if you are not familiarized with the architecture, we highly recommend learning the architecture first before using Rocal. That's the essential part to make your application with Rocal effectively.
Getting Started
fn run() {
migrate!("db/migrations");
route! {
get "/hello-world" => { controller: HelloWorldController, action: index, view: HelloWorldView }
}
}
// ... in HelloWorldController
impl Controller for HelloWorldController {
type View = UserView;
}
#[rocal::action]
pub fn index(&self) {
self.view.index("Hello, World!");
}
// ... in HelloWorldView
pub fn index
…If you've installed Rocal CLI successfully, now you can run this command to initialize your first app.
$ rocal new -n simple_note
You know it's always easy to learn new programming languages or frameworks by making a simple app so let's make a simple note app together. That will manage multiple notes locally that can be accessed even while offline.
Once you ran the new
command, you could see a simple_note directory in your terminal with ls
command.
$ ls
simple_note
Then get into the directory, and see contents.
$ cd simple_note
$ ls
Cargo.toml db index.html js public src sw.js
There are some files and directories already for you to make crafting a modern web app conveniently. I'll explain them in detail later, but before that, let's run this app first by a command below.
$ rocal run
After building successful (it might take a while at the first build since it needs to download all dependencies), you can access http://127.0.0.1:3000 and see a welcome page.
Well done🎉 It's your first time to run a WebAssembly web app!
Wait, but how does it work? How does it show the welcome message?
Let's dive into it. Don't worry, it's very straightforward thanks to Rocal framework.
Take a look at src/lib.rs
first. This is the entry point of the app. It contains configuration, DB-migration setting, and router.
At this time, let's focus on router that is one of the important part to show the welcome page.
$ cat src/lib.rs
use rocal::{config, migrate, route};
mod controllers;
mod models;
mod templates;
mod views;
// app_id and sync_server_endpoint should be set to utilize a sync server.
// You can get them by $ rocal sync-servers list
config! {
app_id: "",
sync_server_endpoint: "",
database_directory_name: "local",
database_file_name: "local.sqlite3"
}
#[rocal::main]
fn app() {
migrate!("db/migrations");
route! {
get "/" => { controller: RootController, action: index, view: RootView }
}
}
As you can see, there is route!
macro. If you are not familiar with Rust, don't worry, macro is just used for Metaprogramming. In this case, the route!
macro provides kind of DSL to set up Router.
route! {
get "/" => { controller: RootController, action: index, view: RootView }
}
The code means that if a user access /
, index
method in RootController
is called and show RootView
. That's all.
That's easy to understand, isn't it?
Then next, to look at how the controller works, open RootController
which is placed in src/controllers/root_controller.rs
.
(Probably, you realized that the framework follows MVC architecture so that everyone easily create an app, FYI.)
$ cat src/controllers/root_controller.rs
use crate::views::root_view::RootView;
use rocal::rocal_core::traits::{Controller, SharedRouter};
pub struct RootController {
router: SharedRouter,
view: RootView,
}
impl Controller for RootController {
type View = RootView;
fn new(router: SharedRouter, view: Self::View) -> Self {
RootController { router, view }
}
}
impl RootController {
#[rocal::action]
pub fn index(&self) {
self.view.index();
}
}
Most of this code is boilerplate. I want you to look at under #[rocal::action]
which is also one of macros Rocal provides to make an action. That is an action which is called when a user access /
and this action calls index
method in RootView
.
I will explain how the view works later though, for your information, this kind of method can take ViewModel
that can be used in View
to show some data.
Next, let's walk through View
and Template
. In Rocal, View
plays a key role of passing data from controller to template. And Template
contains HTML-like DSL powered by Rocal UI which is a built-in simple template engine.
$ cat src/views/root_view.rs
use crate::templates::root_template::RootTemplate;
use rocal::rocal_core::traits::{SharedRouter, Template, View};
pub struct RootView {
router: SharedRouter,
}
impl View for RootView {
fn new(router: SharedRouter) -> Self {
RootView { router }
}
}
impl RootView {
pub fn index(&self) {
let template = RootTemplate::new(self.router.clone());
template.render(String::new());
}
}
There is nothing to pass to template right now. As a placeholder, it just passes empty String.
$ cat src/templates/root_template.rs
use rocal::{
rocal_core::traits::{SharedRouter, Template},
view,
};
pub struct RootTemplate {
router: SharedRouter,
}
impl Template for RootTemplate {
type Data = String;
fn new(router: SharedRouter) -> Self {
RootTemplate { router }
}
fn body(&self, data: Self::Data) -> String {
view! {
<h1>{"Welcome to rocal world!"}</h1>
<p>{{ &data }}</p>
}
}
fn router(&self) -> SharedRouter {
self.router.clone()
}
}
Here is the template. As you can see specifically in fn body()
, there is familiar HTML. It also uses another handful macro view!
. It behaves like a template engine and can have variables
, if-else
condition, and even for-in
loop. If you want to know about view!
in detail, take a look at this post I've written.
That's pretty much all about how Rocal works to produce WebAssembly with Rust.
So I believe you could understand that, then let's build a simple note app to learn practical usage.
Requirements
This is what we will build. In the side section, it has a button on the top left corner to create a new note and list of notes in the below that are already-written notes. In the main section, you can actually compose a note adding title and body. On top of that, There are buttons on the bottom saving changes and removing a note.
Let's begin with making HTML in template.
in src/templates/root_template.rs
use rocal::{
rocal_core::traits::{SharedRouter, Template},
view,
};
pub struct RootTemplate {
router: SharedRouter,
}
impl Template for RootTemplate {
type Data = String;
fn new(router: SharedRouter) -> Self {
RootTemplate { router }
}
fn body(&self, data: Self::Data) -> String {
view! {
<div class="w-screen h-screen">
<header class="flex justify-start drop-shadow-md">
<h1 class="m-4 font-semibold text-xl">{"Simple Note"}</h1>
</header>
<div class="m-6 grid grid-cols-6 gap-4">
<div class="col-span-1">
<form action="/notes">
<button type="submit" class="text-xl">{"+ New"}</button>
</form>
</div>
<div class="col-span-5">
<form action="/notes" method="patch">
<input type="text" name="title" placeholder="Title" class="border-none text-5xl appearance-none w-full py-4 px-3 text-gray-700 leading-tight outline-none" />
<textarea name="body" class="border-none text-4xl appearance-none w-full h-[720px] py-4 px-3 text-gray-700 leading-tight outline-none" placeholder="Type something..."></textarea>
<button type="submit" class="underline p-3 mt-3 text-xl text-gray-800">{"Save changes"}</button>
</form>
</div>
</div>
</div>
}
}
fn router(&self) -> SharedRouter {
self.router.clone()
}
}
I replaced the existing HTML in view!
with the minimal code to create a new note.
To simplify the code, I introduced twind.js
which is light version of Tailwind in public/
where you can put any static files that would be available in the project. And load it in index.html
like below.
twind.js
<script src="./public/twind.js"></script>
There is long HTML though, let's focus on the "new" button to add a new note first.
<div class="col-span-1">
<form action="/notes">
<button type="submit" class="text-xl">{"+ New"}</button>
</form>
</div>
You might think the code tells us that if a user hits the new button, the request would be sent to "/notes" with POST method on a server. Yes, it's kind of true but in Rocal, every request does not go to any server, instead, it is sent to an app running on a browser which means that it completes without the internet.
Let's look into how it works.
As we've seen earlier, there is a router in src/lib.rs
like below.
route! {
get "/" => { controller: RootController, action: index, view: RootView },
post "/notes" => { controller: NotesController, action: create, view: NotesView }
}
Now, I added the second line in route!
so that it can receive requests from the "new" button. This line means requests on POST /notes
come to this route.
Then I will add two files for NotesController and NotesView respectively to satisfy the route.
$ touch src/controllers/notes_controller.rs
$ touch src/views/notes_view.rs
// src/controllers/notes_controller.rs
use rocal::rocal_core::traits::{Controller, SharedRouter};
use crate::views::notes_view::NotesView;
pub struct NotesController {
router: SharedRouter,
view: NotesView,
}
impl Controller for NotesController {
type View = NotesView;
fn new(router: SharedRouter, view: Self::View) -> Self {
Self { router, view }
}
}
impl NotesController {
#[rocal::action]
pub fn create(&self) {}
}
Pretty much everything in the file is boilerplate. The most important part of this code is under #[rocal::action]
which will be written code later to add a new note. #[rocal::action]
is one of macros Rocal provides to define action of controller.
// src/views/notes_view.rs
use rocal::rocal_core::traits::{SharedRouter, View};
pub struct NotesView {
router: SharedRouter,
}
impl View for NotesView {
fn new(router: SharedRouter) -> Self {
Self { router }
}
}
This is a view for NotesController. There is nothing other than boilerplate now since the create
action does not need any view.
OK, because we've finished preparing required files such as NotesController and NotesView to handle creating a new note requests, then get completed create
action in NotesController.
#[rocal::action]
pub fn create(&self) {
let db = CONFIG.get_database().clone();
let result: Result<Vec<NoteId>, JsValue> = db
.query("insert into notes(title, body) values (null, null) returning id;")
.fetch()
.await;
let result = match result {
Ok(result) => result,
Err(err) => {
console::error_1(&err);
return;
}
};
if let Some(note_id) = result.get(0) {
self.router
.borrow()
.redirect(&format!("/?note_id={}", ¬e_id.id))
.await;
} else {
console::error_1(&"Could not add a new note".into());
}
}
If you're not familiar with Rust, it's okey, I will elaborate on the code step by step.
The first line of the code is about getting a new database connection. Rocal provides an in-browser database powered by SQLite so that you could save data like UI state values in local.
let db = CONFIG.get_database().clone();
The next line is obviously to add a new record to note table which should be created before. I will explain later about DB migration.
let result: Result<Vec<NoteId>, JsValue> = db
.query("insert into notes(title, body) values (null, null) returning id;")
.fetch()
.await;
The query gives us a collection of ids, as you can see at the end of the query, there is returning id
that returns a list of ids which contains a new note id. The id will be used in the note page "/".
NoteId
just contains an id that is returned from returning id
like below.
// src/models/note_id.rs
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NoteId {
pub id: i64,
}
Here is just pattern matching to handle the error case of the query. It would be called if the query is failed.
let result = match result {
Ok(result) => result,
Err(err) => {
console::error_1(&err);
return;
}
};
The final section is for redirecting to the note page "/" with note_id
. After extracting a new note id from a list of ids, the router solves redirection to "/?note_id=". That shows the note page that you saw first when you ran this app.
if let Some(note_id) = result.get(0) {
self.router
.borrow()
.redirect(&format!("/?note_id={}", ¬e_id.id))
.await;
} else {
console::error_1(&"Could not add a new note".into());
}
So we have to receive the note id in the index action in RootContrller and pass NoteViewModel including the information of Note and history of notes that have been written to RootView and RootTemplate as well. That is because if there is a note id, the save button in the note page should submit form data to Patch /notes/<note_id>
to update the note but if there is no note id, that should create a new note via Post /notes
.
// src/controllers/root_controller.rs
// ...
#[rocal::action]
pub fn index(&self, note_id: Option<i64>) {
let db = CONFIG.get_database().clone();
let result: Result<Vec<Note>, JsValue> = db
.query("select id, title, body from notes")
.fetch()
.await;
let notes = if let Ok(notes) = result {
notes
} else {
vec![]
};
let note: Option<Note> = if let Some(note_id) = note_id {
notes.iter().find(|note| note.id == note_id).cloned()
} else {
None
};
let vm = RootViewModel::new(note, notes);
self.view.index(vm);
}
As you can see, in index
action, if there is a note_id, it tries to fetch note data by the id then the data is passed to RootViewModel that contains Note and a list of Notes which will be shown in the side of the note page.
// src/view_models/root_view_model.rs
use crate::models::note::Note;
pub struct RootViewModel {
note: Option<Note>,
notes: Vec<Note>,
}
impl RootViewModel {
pub fn new(note: Option<Note>, notes: Vec<Note>) -> Self {
Self { note, notes }
}
pub fn get_note(&self) -> &Option<Note> {
&self.note
}
pub fn get_notes(&self) -> &Vec<Note> {
&self.notes
}
}
Here is the RootViewModel for your information. It just holds a single note and a list of notes.
// src/views/root_view.rs
// ...
impl RootView {
pub fn index(&self, vm: RootViewModel) {
let template = RootTemplate::new(self.router.clone());
template.render(vm);
}
}
The view just passes ViewModel that is received from the controller to the template.
// src/templates/root_template.rs
// ...
impl Template for RootTemplate {
type Data = RootViewModel;
fn new(router: SharedRouter) -> Self {
RootTemplate { router }
}
fn body(&self, data: Self::Data) -> String {
view! {
<div class="w-screen h-screen">
<header class="flex justify-start drop-shadow-md">
<h1 class="m-4 font-semibold text-xl">{"Simple Note"}</h1>
</header>
<div class="m-6 grid grid-cols-6 gap-4">
<div class="col-span-1">
<form action="/notes">
<button type="submit" class="text-xl">{"+ New"}</button>
</form>
<ul>
for note in data.get_notes() {
<li class="m-3">
<a href={{ link_to(&format!("/?note_id={}", ¬e.id), false) }}>
if let Some(title) = ¬e.get_title() {
{{ title }}
} else {
{"Untitled"}
}
</a>
</li>
}
</ul>
</div>
<div class="col-span-5">
if let Some(note) = data.get_note() {
<form action={{ &format!("/notes/{}", ¬e.id) }} method="patch">
if let Some(title) = note.get_title() {
<input type="text" name="title" placeholder="Title" class="border-none text-5xl appearance-none w-full py-4 px-3 text-gray-700 leading-tight outline-none" value={{ title }}/>
} else {
<input type="text" name="title" placeholder="Title" class="border-none text-5xl appearance-none w-full py-4 px-3 text-gray-700 leading-tight outline-none" />
}
<textarea name="body" class="border-none text-4xl appearance-none w-full h-[720px] py-4 px-3 text-gray-700 leading-tight outline-none" placeholder="Type something...">
if let Some(body) = note.get_body() {
{{ body }}
}
</textarea>
<button type="submit" class="underline p-3 mt-3 text-xl text-gray-800">{"Save changes"}</button>
</form>
<button type="submit" class="underline p-3 mt-3 text-xl text-red-700">{"Delete"}</button>
} else {
<form action="/notes" method="post">
<input type="text" name="title" placeholder="Title" class="border-none text-5xl appearance-none w-full py-4 px-3 text-gray-700 leading-tight outline-none" />
<textarea name="body" class="border-none text-4xl appearance-none w-full h-[720px] py-4 px-3 text-gray-700 leading-tight outline-none" placeholder="Type something..."></textarea>
<button type="submit" class="underline p-3 mt-3 text-xl text-gray-800">{"Save changes"}</button>
</form>
}
</div>
</div>
</div>
}
}
fn router(&self) -> SharedRouter {
self.router.clone()
}
}
Importantly, this template is where history of notes is shown and title and body of a note is set on each field.
for note in data.get_notes() {
<li class="m-3">
<a href={{ link_to(&format!("/?note_id={}", ¬e.id), false) }}>
if let Some(title) = ¬e.get_title() {
{{ title }}
} else {
{"Untitled"}
}
</a>
</li>
}
To show history of notes in the side section, you can use for-in
loop to list notes up. And in the inner of the loop, I utilize link_to
that comes with Rocal framework to redirect to a target path. It's most likely to be used with a
tag.
And here is the way to set title and body if there are.
if let Some(title) = note.get_title() {
<input type="text" name="title" placeholder="Title" class="border-none text-5xl appearance-none w-full py-4 px-3 text-gray-700 leading-tight outline-none" value={{ title }}/>
} else {
<input type="text" name="title" placeholder="Title" class="border-none text-5xl appearance-none w-full py-4 px-3 text-gray-700 leading-tight outline-none" />
}
<textarea name="body" class="border-none text-4xl appearance-none w-full h-[720px] py-4 px-3 text-gray-700 leading-tight outline-none" placeholder="Type something...">
if let Some(body) = note.get_body() {
{{ body }}
}
</textarea>
That's really simple. It's just getting title and body and if there are, just set them using value={{ title }}
and <textarea>{{ body }}</textarea>
in a nutshell.
Jusy for your curiosity, I share the code of models and view models below.
// models and view_models
==> src/models/note.rs <==
use serde::Deserialize;
#[derive(Deserialize, Clone)]
pub struct Note {
pub id: i64,
pub title: Option<String>,
pub body: Option<String>,
}
impl Note {
pub fn get_title(&self) -> &Option<String> {
&self.title
}
pub fn get_body(&self) -> &Option<String> {
&self.body
}
}
==> src/view_models/root_view_model.rs <==
use crate::models::note::Note;
pub struct RootViewModel {
note: Option<Note>,
notes: Vec<Note>,
}
impl RootViewModel {
pub fn new(note: Option<Note>, notes: Vec<Note>) -> Self {
Self { note, notes }
}
pub fn get_note(&self) -> &Option<Note> {
&self.note
}
pub fn get_notes(&self) -> &Vec<Note> {
&self.notes
}
}
Overall, I've changed Data
in RootTemplate
from String to RootViewModel to hold a note and a list of notes, additionally, I added if-else
to switch the destination of the form between creating a new note and update an existing note.
Since we want to update an existing note, we have to add code to handle that like we've done for creating a new note. Such as adding a new route and action in route!
and NotesController
respectively like below.
// src/lib.rs
// ...
route! {
// ...
patch "/notes/<note_id>" => { controller: NotesController, action: update, view: NotesView }
}
// src/controllers/notes_controller.rs
// ...
#[rocal::action]
pub fn update(&self, note_id: i64, title: String, body: String) {
let db = CONFIG.get_database().clone();
let result = db
.query("update notes set title = $1, body = $2 where id = $3;")
.bind(title)
.bind(body)
.bind(note_id)
.execute()
.await;
match result {
Ok(_) => {
self.router
.borrow()
.redirect(&format!("/?note_id={}", ¬e_id))
.await;
}
Err(err) => {
console::error_1(&err);
return;
}
};
}
And there is a case creating a note action takes title or body when a user opens the app at the first time and hits save changes
button to create a note, so I've modified the create
action like below.
// src/controllers/notes_controller.rs
// ...
#[rocal::action]
pub fn create(&self, title: Option<String>, body: Option<String>) {
let db = CONFIG.get_database().clone();
let result: Result<Vec<NoteId>, JsValue> = if let (Some(title), Some(body)) = (title, body)
{
db.query("insert into notes(title, body) values ($1, $2) returning id;")
.bind(title)
.bind(body)
.fetch()
.await
} else {
db.query("insert into notes(title, body) values (null, null) returning id;")
.fetch()
.await
};
let result = match result {
Ok(result) => result,
Err(err) => {
console::error_1(&err);
return;
}
};
if let Some(note_id) = result.get(0) {
self.router
.borrow()
.redirect(&format!("/?note_id={}", ¬e_id.id))
.await;
} else {
console::error_1(&"Could not add a new note".into());
}
}
We've almost done making the flow of creating and updating notes but to make this app run actually, there is a last step left which is DB migration. Although we've introduced the code to insert and update queries, there is not a note table right now. So let's try to make a table. Fortunately, Rocal gives us a command to create a migration file.
$ rocal migrate add create-notes-table
Once you ran the command above, you can see a migration file in db/migrations/<timestamp>-create-notes-table.sql
At first the file is completely empty. So we will open the file and complete it.
create table if not exists notes (
id integer primary key,
title text,
body text
);
When you compose a migration file, there is what you have to take care of. You must add if not exists <table_name>
after create table
to avoid table duplication errors. Because of the way to handle migrations in Rocal, the migration files always run when a user accesses a Rocal web app to set up tables or check table availability.
Finally, everything to run this app is completed. Let's run the app!
$ rocal run
If everything is fine, you can see the simple note app like the image above.
Try to take some notes and hit Save
button. You probably can see the note you added in the side section.
You might realize that the deletion button does not work but I will leave it for you to try to implement it by yourself.
Like I said earlier, this app runs on completely your local so even if you stop rocal run
by CTRL-C, you can take notes and save them.
Do you want to publish the app on the internet?
Piece of cake!
$ rocal register # To sign up Rocal platform for free
$ rocal publish
Just sign up Rocal platform for free and run publish
command to go live!
I've released my note app here. Simple Note
There are still some handful features of Rocal like Sync Data with multiple devices though, I could explain basic usage of it and how to build a modern web app with WebAssembly!
If you more curious about the framework and several ways of making apps with WebAssembly and Rust, you can check this repo out and I'd appreciate it if you could drop a star on it.
I really eager any feedback from you. Please leave any comments or feel free to get in touch with me on Github or via email (yoshi@rocal.dev).
Links
- Rocal GitHub Feel free to leave any feedback or issues.
- Rocal Website
Top comments (0)