Introduction

This book gives a very high level overview of the main concepts in Silkenweb. For more detailed documentation on each feature, see the API documentation.

Signals

This chapter teaches you the very basics of Signals. See the futures-signals tutorial for a more thorough introduction.

Signals and Mutables

A Mutable is a variable with interior mutability whose changes can be tracked by a Signal. Lets go through a simple example.

First we create a new Mutable initialized with the value 1:

let x = Mutable::new(1);
assert_eq!(x.get(), 1);

Now lets set up a signal to track changes to x:

let x_signal = x.signal();

By itself, this won't do anything. We have to tell it what we want to do when the signal changes. In this case spawn_for_each will spawn a task on the microtask queue that logs the value of x when it changes.

x_signal.spawn_for_each(|x| {
    web_log::println!("{x}");
    async {}
});

Normally, you won't need to call spawn_for_each as you'll pass the Signal to Silkenweb, and Silkenweb will watch for any changes. We'll see this in action in the chapter on reactivity.

Push/Pull

Signals are both push and pull.

  • They are push, in the sense that the future will notify that it wants to be woken when there is a change. This uses the normal Future polling mechanism.
  • They are pull, in the sense that the future will pull the value from the signal when it is polled.

Streams

Signals are like streams in some ways, but there are differences:

  • Signals are allowed to skip intermediate values for efficiency. In our example, not every intermediate value of x will be printed.
  • Signals always have at least one value, whereas a stream can be empty.

Differential Signals

More complex data types don't always fit well with the normal, value based signals that we've seen so far. It wouldn't be very useful to be notified with a completely new vector every time an element changes, for example. For vectors, we use signal_vec. This allows us to see what's changed in the vector using VecDiff.

Here's how we create a MutableVec and push a value onto it:

let v = MutableVec::new();
v.lock_mut().push(1);

and we can listen for changes with:

v.signal_vec().spawn_for_each(|delta| {
    let action = match delta {
        VecDiff::Replace { .. } => "Replace",
        VecDiff::InsertAt { .. } => "InsertAt",
        VecDiff::UpdateAt { .. } => "UpdateAt",
        VecDiff::RemoveAt { .. } => "RemoveAt",
        VecDiff::Move { .. } => "Move",
        VecDiff::Push { .. } => "Push",
        VecDiff::Pop {} => "Pop",
        VecDiff::Clear {} => "Clear",
    };

    web_log::println!("{action}");
    async {}
})

signal_vec intermediate values can't be discarded like with value signals, as we need to know all the deltas to reconstruct a vector. They can however be combined as an optimization. For example, maybe a push followed by a pop could be discarded. This is implementation defined though, and not guaranteed.

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 Rows 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);