Skip to content

Instantly share code, notes, and snippets.

@dakom
Last active December 11, 2023 05:47
Show Gist options
  • Save dakom/dc39a2ce45a031d62ff27cd28c8dff36 to your computer and use it in GitHub Desktop.
Save dakom/dc39a2ce45a031d62ff27cd28c8dff36 to your computer and use it in GitHub Desktop.

Shortcuts to key official docs

Read these first! :)

Concepts

Low / explicit cost

Generally speaking, you only pay for what you use. Some examples:

Rendering and state changes are completely decoupled

At the most basic level, dynamic properties, attributes, styles, and more are all explicitly bound to Signals. If there's a deeply nested Mutable whose Signal is bound to some property, that Mutable changing will only affect that element's property changing and nothing else. This makes everything extremely fast and there is no need for a virtual DOM.

This has far-reaching implications on more than just bound properties. Let's say you have a component that stores a list but also some other metadata, and you have a UI which is hooked to that list. Only a change to the list itself will force those items to re-render. Going further - if you use MutableVec you get extremely efficient list updates on the DOM for free (including removal/insertion). Compare this to frameworks like React where any inner state change of the component causes the entire list to re-render - even if it was just a metadata change! (though React does mitigate much of the cost via a virtual dom, it isn't free, and working around this requires hooks like useMemo/useEffect.)

Similarly, rendering itself doesn't force the state to be re-evaluated. It's idiomatic in Dominator to keep the rendering separate from State updates. The level of a "component" is a higher-level struct, usually, where rendering is merely a method of that component and updates / reads are other methods.

In other words - everything is explicit, and it's easy to reason about costs

Zero-cost abstractions

If a function is named _clone(), you would probably need to clone anyways, and if it is not named _clone() there is no hidden cost of cloning

The other Rust idioms apply here too (map isn't more expensive than a loop, etc.)

Clone

Functions that result in a signal (e.g. signal() itself, or map() over signal, etc.) must produce new owned values. They cannot return references.

Therefore the value a signal contains must by Copy - or, Clone if using the _cloned() variants.

In many cases - it's therefore better to have a Signal hold an Arc or Rc of a struct rather than the struct itself. That way it is only the (A)Rc which is cloned, rather than the whole struct data.

In terms of Rc vs. Arc - an Arc is required for 'static, but otherwise - Rust types should be Arc and JsValues should be Rc (since they aren't thread-safe anyway)

Note that this is for the resultant values for signal-value-producing functions, it's not a hard rule for the arguments to combinators. For example, both map_ref! and signal_ref() receive references, without any cloning (but they must return new owned values)

The need to clone applies to everything that generates a new signal. So for example, if page is Copy but auth is only Clone here, it must be cloned twice - and so it could be better to wrap it in a RC:

let (page_signal, auth_signal) = (page.signal(), auth.signal_cloned());

map_ref!(page_signal, auth_signal => (*page_signal, auth_signal.clone()))

clone!

Since cloning is explicit, it's ubiquitous on the web. The clone! macro makes it much more ergonomic:

clone!(foo, bar => ...) is equivalent to

let foo = foo.clone();
let bar = bar.clone();

It's most useful for closures (since they must be static so state could be an Rc):

add_some_event_listener(clone!(state => move |_| { ... }))

However it can be used everywhere (async move, map_ref, etc.)

Futures

Signals are built on the Futures and Streams API and Dominator has first-class support for all sorts of interesting interop

DomBuilder.future()

If you have a DomBuilder and add a .future() to it, that future will be dropped when the Dom is.

This can be a very convenient way to react to changes to a signal, outside of all the DomBuilder methods:

html!("div", {
  .future(some_signal().for_each(|value| {
    //do something imperatively with each new value
    async {} //need to return a future
  }))
})

Signal.map_future()

Map a signal with a callback that returns a future. Very useful for hooking up a Mutable/signal to fetch()

signal::from_future()

Turn a future into a Signal, without having an initial signal to start from.

Signal.throttle

Takes a future and waits until it finishes before continuing on.

This is slightly different than a "debounce" which can be implemented as:

foo.map_future(|x| async move { raf().await; x })

If multiple updates happen, throttle() will simply wait until the Future finishes, whereas map_future() will keep reseting the Future over and over again, so map_future might never output any values.

Some shortcuts

Signal - Fuller list on SignalExt

SignalVec - Fuller list on SignalVecExt

Mutable (really everything, but...)

Various functions

Signal <-> SignalVec:

  • to_signal_vec(): Signal<Item = Vec<T>> -> SignalVec<Item = T>
  • to_signal_cloned(): SignalVec<Item = T> -> Signal<Item = Vec<T>>
  • to_signal_map(): SignalVec<Item = A> -> Signal<Item = B>
    • via map function: &[A] -> B
  • map_signal(): SignalVec<Item = A> -> SignalVec<Item = B>
    • via map function A -> Signal<Item = B>

Check the extension docs for the switch combinators which are also very useful (e.g. to go from Signal<SignalVec> -> SignalVec` via a switching function)

Example 1: flatten a MutableVec<Mutable> to get a Signal<Item = String> joined by newline

foo // MutableVec<Mutable<String>>
  .signal_vec_cloned() // SignalVec<Item = Mutable<String>>
  .map_signal(|x| x.signal_cloned()) // SignalVec<Item = String>
  .to_signal_map(|x| x.join("\n")) // Signal<String>

Example 2: Invert a Vec<Signal<Item = T>> to get a Signal<Item = Vec>

signal_vec::always(v)
  .map_signal(|x| x)
  .to_signal_cloned()

Apply / Mixins

You can apply stuff to a DomBuilder by calling .apply(). This is useful for example to only set various properties depending on some state:

.apply(|dom| {
    match foo {
        Foo::Text(text_mutable) => {
            dom.text_signal(text_mutable.signal_cloned())
        },
        Foo::Image(src_mutable) => {
            dom.property_signal("src", src_mutable.signal_cloned())
        }
    }
})

Applying via Iterator

This is very elegant, using fold():

.apply(|dom| {
    WHATEVER.iter().fold(dom, |dom, foo| {
        //specific ops like dom.class(get_class(foo))
        //or apply_methods!(dom, { ... })
    })
})

Mixins

The above examples are from within a DomBuilder. If you need to abstract this into a separate function, you can just chain each method on the dom arg or call apply_methods!. Of course, additional arguments can be provided too:

fn apply_hover<A>(_self: Rc<Self>, dom: DomBuilder<A>) -> DomBuilder<A> {
    apply_methods!(dom, {
        .event(clone!(_self => move |evt:events::MouseEnter| {
            _self.is_hover.set(true)
        }))
    })
}

Having external functions makes it easy to re-use and compose them:

html!("div", {
  .apply(clone!(_self => move |dom| apply_hover(dom, _self))
})

Altogether, the above patterns can be used to create so-called "mixins" - extracting functionality into functions that take and return DomBuilders. They can even be partially applied, e.g.:

//define the mixin
fn on_click<A, F>(f: F) -> impl FnOnce(DomBuilder<A>) -> DomBuilder<A> 
where 
    A: AsRef<HtmlElement>, 
    F: FnMut() + 'static 
{
    move |dom| {
      dom.event(move |_: events::Click| {
        f();
      })
    }
}

//using it is easy!
html!("div", {
    .apply(on_click(...))
})

or:

//define the mixin
fn my_theme<A>() -> impl FnOnce(DomBuilder<A>) -> DomBuilder<A> where A: AsRef<HtmlElement> {
    move |dom| apply_methods!(dom, {
        .style("background-color", "blue")
        .style("color", "gray")
    })
}

//using it is easy!
html!("div", {
    .apply(my_theme())
})

or, passing arguments:

//define the mixin
fn text<'a>(placeholder:Option<&'a str>) -> impl FnOnce(DomBuilder<HtmlInputElement>) -> DomBuilder<HtmlInputElement> + 'a {
  move |dom| {
    dom
      .apply_if(placeholder.is_some(), |dom| {
        dom.property("placeholder", placeholder.unwrap_ext())
      })
  }
}

//using it is easy!
html!("input" => HtmlInputElement, {
  .apply(text(Some("Email address")))
})

DomBuilder vs. Dom

Note that the above all applies to DomBuilder not Dom - generally speaking, Dom is final and if you find yourself trying to apply things to a Dom instead of DomBuilder then something is off and you need to refactor.

Multiple signals

The easiest way to work with multiple signals is map_ref:

map_ref! {
    let foo = foo.signal(),
    let bar = bar.signal() => {
        (*foo, *bar)
    }
}

map_mut! is the same idea but for a &mut

Note that the => {..} is a closure - so in the above example if another value needed to be moved in, it could be like:

map_ref! {
    let foo = foo.signal(),
    let bar = bar.signal() => move {
        (*foo, *bar, *some_enclosed_value)
    }
}

(notice the move keyword)

There's also flatten() to flatten signals of signals, and switch() to change the signal graph itself at runtime. e.g. for a situation like "when this signal is true, then switch to that signal"

For specific use cases like having an Optional signal, a Default value when there is no signal, or exactly 2 signals to merge, see some of the wrappers in dominator_helpers which may help express the intent more clearly (they're especially useful when you have a match statement and each arm returns one of these)

Children

You can have multiple static children (i.e. .child(), .children()) with multiple dynamic children (i.e. .child_signal(), .children_signal_vec(), mixed in any order

Sorting

You can sort a SignalVec via sort_by_cloned() - but be very careful that the sort order is consistent. This is a requirement of some other sortable collections too, not just Dominator-specific.

Here's a simple example of how to deal with a nested mutable which requires dynamic sorting:

fn sorting(xs: impl SignalVec<Item = Mutable<String>>) -> impl SignalVec<Item = String> {
    xs.map_signal(|x| x.signal_cloned())
        .sort_by_cloned(|a, b| a.cmp(b))
}

And a more complex example, using switch + to_signal_map() + to_signal_vec() and replace it wholesale:

.children_signal_vec(state.reverse.signal().switch(clone!(state => move |reverse| {
    state.list.signal_vec_cloned().to_signal_map(move |list| {
        let mut list = list.to_vec();

        list.sort_by(|a, b| {
            let mut ord = a.cmp(b);

            if reverse {
                ord = ord.reverse();
            }

            ord
        });

        list
    })
})).to_signal_vec().map(|item| {
    html!("li", {
        .text(&item)
    })
}))

Enumerate and Len

Enumerate returns a SignalVec of the index and data, and Len returns a Signal of the length

Here's a little recipe to turn all of that into a SignalVec of the index, data, and len (note that len will be cloned for each item):

let foo:Rc<MutableVec<String>> = Rc::new(MutableVec::new_with_values(vec!["hello".to_string()]));

foo
    .signal_vec_cloned()
    .enumerate()
    .map_signal(clone!(foo => move |(index, data)| {
        map_ref! {
            let len = foo.signal_vec_cloned().len(),
            let index = index.signal() 
                => move {
                    (index.unwrap_or_default(), *len, data.clone())
                }
        }
    }))
    .map(|(index, len, data):(usize, usize, String)| {
    });

Re-using signals: Signal factories vs. Broadcaster vs. ReadOnlyMutable

The combinators on Signal generally consume self, so re-using the signal requires more consideration.

Signal factories (i.e. functions that return new Signals) are very common, especially as methods on a struct that contains mutables. However, this does mean that each call will compute the new signal, so if the signal-creation itself is computationally expensive this can be problematic (it's rarely an issue in web applications though). Also, the type system can make it hard to store these functions for later use.

Storing a signal factory can be a bit painful due to the Box/Pin/(R)c requirements, but there are a couple helpers in dominator-helpers to make it easier.

Broadcaster is a good technical solution for re-using a signal without re-creating it, but it's a bit heavy-handed. Storing it also requires infecting the container with all the generics or Boxing it like Broadcaster<Pin<Box<dyn Signal<Item = ...>>>>

ReadOnlyMutable is very ergonomic, easy to use, and efficient - but there's no automatic way to derive it from a Signal. Rather, it requires a bit of manual work (and a teensy problem of computing the value 1 unnecessary time), like this:

let my_signal = {
    map_ref! {
        let value_1 = mutable_1.signal(),
        let value_2 = mutable_2.signal()
            => {
                compute_something(*value_1, *value_2)
        }
    }
};

let initial_value = compute_something(mutable_1.get(), mutable_2.get());
let my_mutable = Mutable::new(initial_value);

spawn_local(clone!(my_mutable => async move {
    let _ = my_signal.for_each(clone!(my_mutable => move |value| {
        my_mutable.set_neq(my_mutable);
        async {}
    })).await;
}));

///Pass this everywhere!
let my_readonly_mutable = my_mutable.read_only();

The above works because:

No memory leaks:

  1. spawn_local() uses a JS promise to drive the polling, but the Future itself remains in the Rust side, and will be dropped when it's finished.
  2. for_each() is dropped when the Signal is finished
  3. my_signal is dropped when the mutables it's derived from are dropped
  4. read_only_mutable is a weak reference, so it doesn't prevent anything from being dropped.
  5. No cycles, the flow is unidirectional: mutable_n -> my_signal -> my_mutable -> read_only_mutable

Correct values:

  1. If the source mutables are never held for updating (i.e. in an event listener), then my_readonly_mutable has the initial value and all is good even when everything is dropped.
  2. If those mutables are held, then the signal will be held, and thus the for_each() will continue to exist and spawn_local won't drop it.

Note that this could be "simplified" to not needing any of the spawn_local of extra mutable stuff, to a Broadcaster, by shifting the burden to the type system difficulties:

let my_readonly_mutable = Broadcaster::new(my_signal.dedupe());

or with boxing:

Broadcaster::new(my_signal().dedupe().boxed_local());

Despite looking simpler, it's slightly less performant, and creates a more complicated interface if the Broadcaster needs to be stored elsewhere.

CSS

There are three fundamental macros to create CSS in Dominator: class!, pseudo! and stylesheet!

class!

Creates a CSS class with a unique name, adds it to the current document's stylesheet, and returns the name of that class. This name can then be passed to DomBuilder::class(), for example.

Because it adds it to the document, it's better to call it in some sort of lazy static / one-time initialization

Example:

static FOO_CLASS: Lazy<String> = Lazy::new(|| {
    class! {
        .style("background-color", "black")
        .style("cursor", "pointer")
    }
});

html!("div", {
  .class(&*FOO_CLASS)
})

pseudo!

pseudo! will crate variations of the class with the argument. It can be used to add genuine pseudo selectors like :hover or any additional selectors to the class. For example:

static FOO_CLASS: Lazy<String> = Lazy::new(|| {
    class! {
        .style("background-color", "black")
        .pseudo!(":hover", {
          .style("background-color", "black")
        })
    }
});

or

static FOO_CLASS: Lazy<String> = Lazy::new(|| {
    class! {
        .style("display", "flex")
        .pseudo!(" > * + *", {
          .style("margin-left", "10px")
        })
    }
});

Setting classes statically

DomBuilder's class method can accept anything that impls MultiStr such as a string slice like .class(&[&*FOO_CLASS, &*BAR_CLASS])

MultiStr isn't implemented for everything, for example it isn't implemented for Vec, but it can be gotten via RefFn like:

RefFn::new(v, |v| v.as_slice()) // returns an impl of MultiStr

However, it's usually simpler to use fold() and apply() (see Applying via an Iterator above)

Setting classes dynamically

The main workhorse is .class_signal() - give it a class (or more specifically, a MultiStr) and a Signal<Item = bool> which will cause the class(es) to be set/unset.

This does mean that you need a unique signal per class(es), which may sometimes mean using Broadcaster or a signal factory

However, if it's okay to completely replace all the classes, one signal can be used to set the classes via .attr_signal("class", ...)

Note that dynamic classes must not overlap, Dominator has no way of knowing whether a set or unset comes first, so keep the static parts as just regular .class() and then have unique dynamic parts. For example:

html!("div", {
  .class(["inline-flex","items-center","px-1","pt-1","border-b-2","text-sm","font-medium"])
  .class_signal(["border-indigo-500","text-gray-900"],
    state.selected_signal()
  )
  .class_signal(["border-transparent","text-gray-500","hover:border-gray-300","hover:text-gray-700"], 
    state.selected_signal().map(|x| !x)
  )
})

stylesheet!

Creates a full Stylesheet and adds it to the document. There is nothing returned to hook to DomBuilder, rather, this is used for declaring global styles for elements.

Conceptually, it's similar to importing a global .css file, but it adds in dynamic styles (via style_signal()), type checking, vendor prefixing, etc. This should also be done once at initialization, but there's no global variable to assign it to.

Example:

stylesheet!("html, body", {
  .style("width", "100%")
  .style("height", "100%")
})

Misc

Consider

timestamps() 
  .throttle(|| delay_somehow())
  .map(|time| do_something_expensive());

The throttle will do its job and prevent do_something_expensive() from happening too often - but it doesn't stop the signals from being polled.

Polling is incredibly cheap, so it's probably not a big deal, but just keep in mind that throttling stops the computation from happening, not the polling.

The reason is that polling goes from bottom to top across everything in the same Task, so in this example within a given Task it will poll the Map then the Throttle, then the Timestamp

Elements

If you happen to have a native web_sys HtmlElement you can get a Dom from it via Dom::new(elem.into()) or, if it needs dominator methods:

dom_builder!(elem, {
    .style("foo", "bar")
    // ...
})

Animation

Dominator offers some simple but powerful animation primitives. As an example:

  1. Create an animation for some duration:
let animation = MutableAnimation::new(duration);
  1. Animate it. Note that Percentage::START is 0.0 and Percentage::END is 1.0
animation.animate_to(Percentage::END);
  1. Map the Signal to some range, with easing
let value_signal = animation
    .signal()
    .map(|t| easing::in_out(t, easing::cubic))
    .map(|t| t.range_inclusive(my_start, my_end);
  1. Use it
html!("div", {
    .style_signal("opacity", value_signal.map(|value| format!("{}", value)))
})

Implementation details

Streams vs. Signals and the Microtick

The Signals Tutorial (see above) covers the essence.

Picking up from there, keep in mind that Streams and Signals are Traits. It is possible to implement them in broken ways that don't hold up to their contract or typical use. What follows is assuming that we're talking about robust implementations such as MutableSignal for Signal and UnboundedReceiver for Stream.

  1. Streams require keeping a history of past values and can only "forget" them when polled. The advantage here is that no values are ever missed. The disadvantage is that it can cost a lot of memory to keep an indefinite history (especially when you're only interested in the most recent value!)
  2. Signals only have the single latest value. The advantage here is extremely low memory footprint. The disadvantage is it's lossy.

So when do Streams get polled? When do Signals have their latest value? There's two parts to this:

  1. The timing of the polling is globally driven by the native Rust Future ecosystem and executor. In Web Assembly, this translates to the native microtask queue

  2. The typical way to say "poll me", is via a listener of sorts, e.g. for_each. This method exists on both StreamExt and SignalExt

So let's say we update a Stream multiple times within that microtick (i.e. via UnboundedSender.send()), we also update a Signal multiple times within that microtick (i.e. via Mutable.set()), and we call for_each on each of these. Here's what will happen:

  1. The Stream will accumulate all those values during the microtick. Once it's polled, it will continue to poll until it has no more values. (Note that this "polling until complete" isn't part of the Stream contract per se, but rather it's the implementation of for_each)
  2. The Signal will only keep the most recent value. Once it's polled, it will return that final value.
  3. Polling for both of them will happen at the end of the microtick.

This separation allows all sorts of interop between the two worlds via a clean API, without unnecessary costs.

EventStream

pub struct EventStream {
    receiver: mpsc::UnboundedReceiver<Event>,
    _listener: EventListener,
}

impl EventStream {
    pub fn new<N>(target: &EventTarget, name: N) -> Self where N: Into<Cow<'static, str>> {
        let (sender, receiver) = mpsc::unbounded();

        let listener = EventListener::new(target, name, move |event| {
            sender.unbounded_send(event.clone()).unwrap();
        });

        Self {
            receiver,
            _listener: listener,
        }
    }
}

impl Stream for EventStream {
    type Item = Event;

    #[inline]
    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        self.receiver.poll_next_unpin(cx)
    }
}

Usage:

struct App {
    worker: web_sys::Worker,
}

impl App {
    fn new() -> Result<Arc<Self>, JsValue> {
        let worker = web_sys::Worker::new("./worker.js")?;
        Ok(Arc::new(Self {
            worker,
        }))
    }

    fn render(state: Arc<Self>) -> Dom {
        let signal = from_stream(EventStream::new(&state.worker, "message")
            .map(|event| event.unchecked_into::<web_sys::MessageEvent>())
            .map(|event| event.data()));

        //...
    }
}
@pauldorehill
Copy link

Hi David,

Following my recent enlightenment on discord around the utility of map_signal, I'd suggest a useful update to the sorting section would be to add something like the following which solves the It is a logic error for a key to be modified in such a way that the key’s ordering relative to any other key, as determined by the Ord trait, changes while it is in the map. and still use sort_by_cloned

fn sorting(xs: impl SignalVec<Item = Mutable<String>>) -> impl SignalVec<Item = String> {
    xs.map_signal(|x| x.signal_cloned())
        .sort_by_cloned(|a, b| a.cmp(b))
}

@dakom
Copy link
Author

dakom commented Apr 22, 2021

Thanks, added that in :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment