Reactivity
A Simple App
Lets create a simple example app to see how Silkenweb handles changes to underlying data. We'll use a very simple counter for our example. First off, we need something to store the count:
let count = Mutable::new(0);
Next we need a signal so Silkenweb can react to changes to the count:
let count_signal = count.signal();
This signal is an integer type, and we want a String
signal that we can use
as the text of a DOM element. We use map
to get a new String
signal:
let count_text = count_signal.map(|n| format!("{n}"));
Now we can specify what we want the DOM tree to look like:
let app = p().text(Sig(count_text));
Investigating Reactivity
Lets see how our app reacts to changes in count
. We're going to use Server
Side Rendering (SSR) as this gives us more explicit control over the
microtask queue, so we can see whats going on. Converting our app to a
Node<Dry>
means we can render it to a String
on the server side (this
is what the Dry
DOM type is):
let node: Node<Dry> = app.into();
Now we'll add a convenience function to see what our app looks like at various points in time:
fn check(app: &Node<Dry>, expected: &str) {
assert_eq!(app.to_string(), expected);
}
The first thing you might notice is that our app doesn't contain any text yet:
check(&node, "<p></p>");
This is a because we haven't processed the microtask queue yet, so lets do that:
render_now_sync();
check(&node, "<p>0</p>");
Now we can see that count
has been rendered. Similarly, if we change
count
, it doesn't immediately have an effect:
count.set(1);
check(&node, "<p>0</p>");
We need to process the microtask queue again, then our app will update:
render_now_sync();
check(&node, "<p>1</p>");
Event Handlers
Normally you'd update your Mutable
data with an event handler. Here's an
example counter app with a button that updates the count when clicked:
let count = Mutable::new(0);
let count_text = count.signal().map(|n| format!("{n}"));
let increment = button().text("Increment").on_click(move |_, _| {
count.replace_with(|n| *n + 1);
});
let app = div().child(p().text(Sig(count_text))).child(increment);
mount(app);
Children
So far we've seen how we can add reactivity to the text in a DOM node. This is great, but very limiting. It would be much more powerful if we could change the structure of the DOM based on our data.
To do this, we can set the children of any DOM node based on a signal. There are 3 ways of doing this:
- add a single child based on a signal
- add an optional child based on a signal
- add and remove multiple children based on a vector signal
Single Child Signal
We'll write a very simple app with 2 tabs as an example. Each tab will have a different element type, and we'll just switch between tabs by setting a mutable, rather than wiring up buttons to change the tab.
First we define an enum to represent which tab is selected, and a
Mutable
to hold the current state.
#[derive(Copy, Clone)]
enum Tab {
First,
Second,
}
let tab = Mutable::new(Tab::First);
Next, we need a way to render a tab:
impl Tab {
fn render(self) -> Node {
match self {
Tab::First => p().text("First").into(),
Tab::Second => div().text("Second").into(),
}
}
}
We have to convert the element into a Node
because the elements for each
tab are different types.
Now we map a signal from the current tab to the element we want to display:
let tab_element = tab.signal().map(Tab::render);
And define our app:
let app = div().child(Sig(tab_element)).into();
Then render the initial page.
render_now_sync();
check(&app, "<div><p>First</p></div>");
As we would expect, when we change the tab through the Mutable
, our app
updates:
tab.set(Tab::Second);
render_now_sync();
check(&app, "<div><div>Second</div></div>");
Optional Child Signal
Optional child signals are much the same as child signals, but the
element will appear and disappear base on whether the value is currently
Some
or None
.
Child Signal Vector
Sometimes we might want to have a variable number of children, based on some
data. For example, we might want to display the results of a database query
in a table. Lets create a simple app that will display a vector of data, to
serve as an example. First we'll define a row type, and a MutableVec
to
hold the records:
#[derive(Clone)]
struct Row {
field1: usize,
field2: String,
}
let data = MutableVec::new_with_values(vec![Row {
field1: 0,
field2: "Rita".to_string(),
}]);
Now we need a way to render rows:
impl Row {
fn render(self) -> Tr {
let field1 = td().text(self.field1.to_string());
let field2 = td().text(&self.field2);
tr().children([field1, field2])
}
}
Next, we define a signal that maps Row
s to rendered rows:
let data_elements = data.signal_vec_cloned().map(Row::render);
Now we can define our app, with a table based on data
:
let app = table().children_signal(data_elements).into();
Initially we'll just see a table with one row:
render_now_sync();
check(&app, "<table><tr><td>0</td><td>Rita</td></tr></table>");
When we add some data, we can see that our table gets updated:
data.lock_mut().push_cloned(Row {
field1: 1,
field2: "Sue".to_string(),
});
render_now_sync();
check(
&app,
&[
"<table>",
"<tr><td>0</td><td>Rita</td></tr>",
"<tr><td>1</td><td>Sue</td></tr>",
"</table>",
]
.join(""),
);
Mixing and Matching Reactive Children
We can mix and match the methods we've seen so far, on a single parent element. For example:
let data_elements = data.signal_vec_cloned().map(Row::render);
let tab_node = tab.signal().map(Tab::render);
let app = div().children_signal(data_elements).child(Sig(tab_node));
mount(app);