Read these first! :)
Generally speaking, you only pay for what you use. Some examples:
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
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.)
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()))
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.)
Signals are built on the Futures and Streams API and Dominator has first-class support for all sorts of interesting interop
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
}))
})
Map a signal with a callback that returns a future. Very useful for hooking up a Mutable/signal to fetch()
Turn a future into a Signal, without having an initial signal to start from.
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.
Signal - Fuller list on SignalExt
SignalVec - Fuller list on SignalVecExt
- SignalVec::map
- SignalVec::map_signal
- SignalVec::filter
- SignalVec::sort_by_cloned
- SignalVec::for_each
- 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
- via map function:
- map_signal():
SignalVec<Item = A> -> SignalVec<Item = B>
- via map function
A -> Signal<Item = B>
- via map function
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)
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>
signal_vec::always(v)
.map_signal(|x| x)
.to_signal_cloned()
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())
}
}
})
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, { ... })
})
})
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")))
})
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.
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)
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
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 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)| {
});
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:
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.for_each()
is dropped when the Signal is finishedmy_signal
is dropped when the mutables it's derived from are droppedread_only_mutable
is a weak reference, so it doesn't prevent anything from being dropped.- No cycles, the flow is unidirectional:
mutable_n
->my_signal
->my_mutable
->read_only_mutable
Correct values:
- 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. - 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.
There are three fundamental macros to create CSS in Dominator: class!
, pseudo!
and stylesheet!
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!
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")
})
}
});
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)
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)
)
})
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%")
})
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
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")
// ...
})
Dominator offers some simple but powerful animation primitives. As an example:
- Create an animation for some duration:
let animation = MutableAnimation::new(duration);
- Animate it. Note that Percentage::START is 0.0 and Percentage::END is 1.0
animation.animate_to(Percentage::END);
- 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);
- Use it
html!("div", {
.style_signal("opacity", value_signal.map(|value| format!("{}", value)))
})
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.
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!)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:
-
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
-
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:
- 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)
- The Signal will only keep the most recent value. Once it's polled, it will return that final value.
- 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.
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)
}
}
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()));
//...
}
}
Thanks, added that in :)