Routing
Any modern web application needs to support routing. DOMINATOR provides some utilities on top of the browsers history APIs to make integrating routing with our futures-signals based application state easier. Let's get started!
url()
The starting point is dominator::routing::url()
, which returns a ReadOnlyMutable<String>
.
This allows us to read the current url directly, but even more importantly, it lets us get a signal for it!
// Print the current value
let url = dominator::routing::url();
info!("You are currently at: {}", url.get_cloned());
// Print every new url
url.signal_cloned().for_each(|new_url| async move {
info!("You are now at: {}", new_url)
}).await;
To change the current URL, we use the goto_url
function
dominator::routing::go_to_url("#/new-url");
We can make a simple application with two routes: #/hello
and a default.
fn hello_world() -> Dom {
let child = url()
.signal_ref(|current_route| {
web_sys::Url::new(current_route.as_str()).expect_throw("Invalid url")
})
.map(
|current_route: web_sys::Url| match current_route.hash().as_str() {
"#/hello" => Some(html!("span", { .text("Hello, world!")})),
_ => Some(html!("span", { .text("Unknown route, sorry!") })),
},
);
html!("div", {
.child(html!("h1", { .text("Simple routing example")}))
.child_signal(child)
})
}
Note how we create a web_sys::Url
from the route string.
This gives some utility, for our case we mostly care about the hash()
method to make matching on relative URLs easier.
The route enum
.. The above example may be all we need for a simple application, but there are also cases were we wish to avoid directly hard coding route strings all over our application code. There are several ways of solving this, one of which is to model the route states as rust enums. We can then write perfectly type-safe routing code, and it also gives us a very structured model of the possible application states!
Let's create an imaginary application. It has a landing page, which is also the default route. There's a "Shop now" view, which additionally can have an item ID as a sub-view for direct linking to items.
We can start by declaring the route enum:
enum ShopRoutes {
LandingPage,
Shop { display_item_id: Option<String> },
}
impl Default for ShopRoutes {
fn default() -> Self {
ShopRoutes::LandingPage
}
}
And our application can accept an instance of this enum to render:
fn shop_application(route: impl Signal<Item = ShopRoutes>) -> impl Signal<Item = Option<Dom>> {
route.map(|new_route| match new_route {
ShopRoutes::Shop { display_item_id } => match display_item_id {
Some(item_id) => Some(
html!("span", { .text(format!("Displaying item with id {}", item_id).as_str()) }),
),
None => Some(html!("span", { .text("Displaying all items") })),
},
_ => Some(html!("Welcome to our shop!")),
})
}
With that out of the way, we need to somehow connect to the routing itself. Again, there are several ways to do this, but one compact and nice-ish way is to create a few static methods on the enum itself.
We need a function to provide us with a signal of the enum value representing the current route. We also need to be able to modify the route.
impl ShopRoutes {
pub fn signal() -> impl Signal<Item = Self> {
let shop_item_regex = Regex::new(r"#/shop/(?<item_id>[0-9]+)").unwrap();
url().signal_ref(move |new_route_path| {
let url = web_sys::Url::new(new_route_path.as_str()).expect_throw("Invalid url");
let hash = url.hash();
if let Some(captured_item_id) = shop_item_regex
.captures(hash.as_str())
.map(|captures| captures["item_id"].to_string())
{
return Self::Shop {
display_item_id: Some(captured_item_id),
};
}
match hash.as_str() {
"#/shop" => ShopRoutes::Shop {
display_item_id: None,
},
_ => ShopRoutes::LandingPage,
}
})
}
pub fn goto(route: Self) {
go_to_url(route.to_url().as_str());
}
pub fn to_url(&self) -> String {
match self {
ShopRoutes::LandingPage => "#/landing".to_string(),
ShopRoutes::Shop { display_item_id } => match display_item_id {
Some(item_id) => format!("#/shop/{}", item_id),
None => "#/shop".to_string(),
},
}
}
}
Now we can put it all together to render a strongly typed, routed application:
fn route_enum_example() -> Dom {
let route_signal = ShopRoutes::signal();
html!("div", {
.child(html!("h1", { .text("Route enum example") }))
.child(html!("div", {
.child(html!("span", {
.text("Landing Page")
.event(|_: dominator::events::Click| {
ShopRoutes::goto(ShopRoutes::LandingPage)
})
}))
.child(html!("span", {
.text("Shop")
.event(|_: dominator::events::Click| {
ShopRoutes::goto(ShopRoutes::Shop { display_item_id: None })
})
}))
.child(link!(
ShopRoutes::Shop { display_item_id: Some("1234".to_string())}.to_url(),
{
.text("Daily offer!")
}))
}))
.child(html!("div", {
.class("main-view")
.child_signal(shop_application(route_signal))
}))
})
}
Notice we use the link!
macro provided by tokio for a shorthand.
This creates an <a>
element with on click set correctly for us!
Generalized router
We can generalize and simplify the route handling a bit.
For instance, we can build a generic router based on the matchit
crate (which is use din the popular backend library axum
).
It is essentially a set of routes, associated with a lambda function to extract the matched parameters and translate them into our desired route value (typically our enum from up-top)
use matchit::{Params, Router};
use wasm_bindgen::UnwrapThrowExt;
use dominator::routing::url;
use futures_signals::signal::Signal;
use futures_signals::signal::SignalExt;
pub struct AppRouter<TValue> {
router: Router<Box<dyn Fn(Params) -> Result<TValue, ()>>>,
}
impl<TValue> AppRouter<TValue> where TValue: Default {
pub fn new(router: Router<Box<dyn Fn(Params) -> Result<TValue, ()>>>) -> Self {
Self { router }
}
#[inline]
fn match_url(&self, url: impl AsRef<str>) -> Result<TValue, ()> {
let matched = self.router.at(url.as_ref()).map_err(|_| ())?;
(matched.value)(matched.params)
}
pub fn signal(self) -> impl Signal<Item=TValue> {
url().signal_ref(|current_route| {
web_sys::Url::new(current_route.as_str()).expect_throw("Invalid url")
}).map(move |new_url| {
if let Ok(route) = self.match_url(new_url.hash().as_str()) {
info!("url: {}", new_url.hash().as_str());
route
} else {
info!("unmatched url: {}", new_url.hash().as_str());
TValue::default()
}
})
}
}
This makes it super easy to wire our application routes using lambdas.
We simply register handler functions for our chosen route patterns on the matchit
router instance.
These lambdas then return the value we wish to receive from the app routers signal.
let mut router = matchit::Router::<Box<dyn Fn(Params) -> Result<ShopRoutes, ()>>>::new();
// Configure a callback on the router for matching a certain route, and convert it to our application enum type
router.insert("#/shop/{id}", Box::new(|params: Params| {
let item_id = params.get("id").ok_or_else(|| ())?;
Ok(ShopRoutes::Shop { display_item_id: Some(item_id.to_string()) })
})).unwrap_throw();
let route_signal = AppRouter::new(router).signal();