A List of Components
A list of components is a common interface pattern. In mogwai
we can express this using two component
definitions. In this example we'll use a list
component and an item
component.
Contents
Explanation
Views
The list
defines its view to include a button
to create new items and an ol
to hold them.
Messages will flow from the view into two async tasks, which will patch the items using the patch:children
RSX attribute (which we can be set to anything that implements Stream<Item = ListPatch<ViewBuilder<JsDom>>
).
rsx! {
fieldset() {
legend(){ "A List of Gizmos" }
button(
style:cursor = "pointer",
on:click = new_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Create a new item"
}
fieldset() {
legend(){ "Items" }
ol(
patch:children = items
.stream()
.map(move |patch| map_item_patch(patch, remove_item_clicked_patch.clone()))
){}
}
}
}
Each item has a unique id
that helps us determine which items to remove.
Each item contains a button to remove the item from the list as well as a button to increment a counter. Click events on these buttons will be as an output into an async task that determines what to do next.
The view receives a count of the current number of clicks from a stream and uses that to display a nice message to the user.
rsx! {
li() {
button(
style:cursor = "pointer",
on:click = increment_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Increment"
}
button(
style:cursor = "pointer",
// every time the user clicks, we'll send the id as output
on:click = remove_item_clicked.sink().contra_map(move |_: JsDomEvent| id)
) {
"Remove"
}
{" "}
span() {
{
("", num_clicks.stream().map(|clicks| match clicks {
1 => "1 click".to_string(),
n => format!("{} clicks", n),
}))
}
}
}
}
Logic and Communication
Just like most other components, both list
and item
have their own async tasks that loop over incoming messages.
These messages encode all the events that occur in our views.
The difference between this list of items and other less complex widgets is the way items communicate up to the parent list and how view updates are made to the parent list.
As you can see below, item
takes the item ID _and a remove_item_clicked: Output<ItemId>
, which it plugs into
the "remove" button's on:click
attribute.
This sends any click events straight to the the caller of item
.
It is this output that list
uses to receiver remove events from its items.
Also notice that we're using Model
to keep the state of the number of clicks.
Model
is convenient here because it tracks the state and automatically streams any
updates to downstream observers.
/// An id to keep track of item nodes
#[derive(Clone, Copy, Debug, PartialEq)]
struct ItemId(usize);
/// Creates an individual item.
///
/// Takes the id of the item (which will be unique) and an `Output` to send
/// "remove item" click events (so the item itself can inform the parent when
/// it should be removed).
fn item(id: ItemId, remove_item_clicked: Output<ItemId>) -> ViewBuilder {
let increment_item_clicked = Output::<()>::default();
let num_clicks = Model::new(0u32);
rsx! {
li() {
button(
style:cursor = "pointer",
on:click = increment_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Increment"
}
button(
style:cursor = "pointer",
// every time the user clicks, we'll send the id as output
on:click = remove_item_clicked.sink().contra_map(move |_: JsDomEvent| id)
) {
"Remove"
}
{" "}
span() {
{
("", num_clicks.stream().map(|clicks| match clicks {
1 => "1 click".to_string(),
n => format!("{} clicks", n),
}))
}
}
}
}
.with_task(async move {
while let Some(_) = increment_item_clicked.get().await {
num_clicks.visit_mut(|n| *n += 1).await;
}
log::info!("item {} loop is done", id.0);
})
}
The list
uses ListPatchModel
to maintain our list if items.
ListPatchModel
is much like Model
but is special in that every time itself is patched
it sends a clone of that patch to downstream listeners.
So while Model
sends a clone of the full updated inner value, ListPatchModel
sends the diff.
This makes it easy to map the patch from ItemId
to ViewBuilder
and use the resulting stream to patch the list's view:
// Maps a patch of ItemId into a patch of ViewBuilder
fn map_item_patch(
patch: ListPatch<ItemId>,
remove_item_clicked: Output<ItemId>,
) -> ListPatch<ViewBuilder> {
patch.map(|id| item(id, remove_item_clicked.clone()))
}
/// Our list of items.
///
/// Set up our communication from items to this logic loop by
/// * giving each created item a clone of a shared `Output<ItemId>` to send events from
/// * creating a list patch model to update from two separate async tasks (one to create, one to remove)
/// * receive output "removal" messages and patch the list patch model
/// * receive output "create" messages and patch the list patch model
fn list() -> ViewBuilder {
let remove_item_clicked = Output::<ItemId>::default();
let remove_item_clicked_patch = remove_item_clicked.clone();
let new_item_clicked = Output::<()>::default();
let items: ListPatchModel<ItemId> = ListPatchModel::new();
let items_remove_loop = items.clone();
rsx! {
fieldset() {
legend(){ "A List of Gizmos" }
button(
style:cursor = "pointer",
on:click = new_item_clicked.sink().contra_map(|_: JsDomEvent| ())
) {
"Create a new item"
}
fieldset() {
legend(){ "Items" }
ol(
patch:children = items
.stream()
.map(move |patch| map_item_patch(patch, remove_item_clicked_patch.clone()))
){}
}
}
}
.with_task(async move {
// add new items
let mut next_id = 0;
while let Some(_) = new_item_clicked.get().await {
log::info!("creating item {}", next_id);
let id = ItemId(next_id);
next_id += 1;
let patch = ListPatch::push(id);
items.patch(patch).await.expect("could not patch");
}
log::info!("list 'add' loop is done - should never happen");
}).with_task(async move {
// remove items
while let Some(remove_id) = remove_item_clicked.get().await {
let items_read = items_remove_loop.read().await;
let index = items_read.iter().enumerate().find_map(|(i, id)| if id == &remove_id {
Some(i)
} else {
None
}).unwrap();
drop(items_read);
log::info!("removing item {} at index {}", remove_id.0, index);
let patch = ListPatch::remove(index);
items_remove_loop.patch(patch).await.expect("could not patch");
}
log::info!("list 'remove' loop is done - should never happen");
})
}
This is a good example of how mogwai
separates component state from component views. The list logic doesn't own the
view and doesn't maintain the list of DOM nodes. Instead, the view has a patching mechanism that is set with a stream and then the logic maintains a collection of ItemId
s that it patches locally - triggering downstream patches to the view automatically.
Notes
In this example you can see that unlike other vdom based libraries, mogwai's state is completely separate from its views. Indeed they even require separate handling to "keep them in sync". This is by design. In general a mogwai app tends to be more explicit and less magical than its vdom counterparts.
Code
You can see the code in its entirety at mogwai's list of components example.