Skip to content

rsact_reactive

Hazer Hazer edited this page Nov 28, 2024 · 7 revisions

rsact_reactive is what rsact states are backed by, fine-grained reactivity framework. Fine-grained reactivity means that only particular parts of states are tracked by reactive runtime, and it's up to you and me to explicitly state that some value is reactive.

Note

This is mostly no-code documentation as I don't have time to document every aspect of the framework. For method signatures and typing better look at the source code which should have sufficient descriptions.

Signal — the value that changes over time

Signal is most the basic thing in rsact_reactive, it can be mutated and tracked. To create a signal, use create_signal(value) function, to get the value you use get (for Copy-types), get_cloned (for Clone-types) and with to apply mapping function on the inner value of a signal by reference. These getters methods are implemented by all readable reactive types. Though Signal is a distinct read-write reactive type, I'll call other reactive types signals as by theory "signal" is something changing over time, which is true for memos too.

Example:

let signal = create_signal(123);

assert_eq!(signal.get(), 123);
assert_eq!(signal.get_cloned(), 123);
assert!(signal.with(|value| *value == 123));

To write signal value, there are set and update methods.

signal.set(69);
signal.update(|value| *value = 69); // does the same

While getter methods get, get_cloned and with subscribe to signal changes in the current scope, set and update emit mutation event. Not raw Rust scopes are tracked, but such that you create explicitly through create_memo, create_effect, etc.

Effects — react to signal change

Effects define how to behave when tracked signals change. It is simple: every tracked signal change used inside effect reruns the effect. This code prints signal value each time it changes.

let signal = create_signal(123);

create_effect(move |_| {
    println!("{}", signal.get())
});

signal.set(222);
signal.set(333);
// This will reprint `333`, signals are not checked for real change
signal.set(333);

Note

It is important to remember that signals do not check that value actually changed and notify effects on each set or update call.

create_memo accepts a function with single parameter - the previous effect computation result of type Option<T>, as it is None on first effect run.

Note

Effects always run at least once, on creation, then it subscribes to all reactive values used inside.

Caution

You rarely need effects in practice, and I don't recommend using them. In most cases you need memos, effects are pretty dangerous and bug-prone. How to avoid problems with effects:

  • Better do not create new reactive values inside effects.

Memos — don't rerun without actual change

Memo is mapping reactive read-only type, you create memo depending on one or more reactive values, optionally doing some computations with them and give a result. The type of memo result is required to implement PartialEq, because memos check whenever the value actually changed. This is done because of one simple statement: in most cases, comparing two results of memo computation is much cheaper than rerunning all logic dependent on this memo.

let s = create_signal(1);
let memo = create_memo(move |_| s.get() + 1);

assert_eq!(memo.get(), 2);
assert_eq!(memo.get(), 2);

s.set(10);
assert_eq!(memo.get(), 11);

Unlike effects, memos are not called until used, so in this example, the only time memo is invoked is when we call memo.get(). The second assertion won't rerun memo at all, as all its dependencies (signal s) are unchanged. But after setting s, next memo usage will know that its dependencies are "dirty". Same as for effects, memos callback accepts single parameter -- previous memo result. It is rarely used, but sometimes you can avoid expensive computations depending on some state and just return previous result. Whereas

Derived signals

This is not a distinct type, just a fancy name for a function that uses reactive values.

let signal = create_signal(123);
let derived = move || signal.get() + 100;

This is an example of derived signal. Unlike memos, they are not tracked and don't require result to implement PartialEq, but each call will rerun this derived signal.

ReadSignal

ReadSignal is a trait implemented by all readable signal types. It includes implementation-specific track and with_untracked functions:

  • track is called when tracking getter is used.
  • with_untracked is what gets the value of the signal from storage.

All other functions are implemented (except special cases) using these two and provide default implementations. So, with = track + with_untracked, get = with(|value| *value) and get_cloned = with(|value| value.get_cloned()).

with_untracked does not subscribe current scope to signal changes, and you can use it for this need. In addition to with_untracked there's get_untracked for copy-types.

SignalMap

This trait is very useful, it maps a reactive value to produce reactive value, mirroring T -> U, SignalMap is SignalType<T> -> SignalType<U>. SignalMap as ReadSignal is implemented for all readable signals, such as Signal and Memo. SignalMap associated with Output type, which, in most cases, is Memo, this is because Signal is not computed but Memo is. For simplicity, consider implementation of SignalMap for Signal<T> and Memo<T>, they are the most commonly used:

  • map<U>(f): maps T into U, producing Memo<U>
  • map_cloned<U>(f): same as map but mapping function accepts T instead of &T, cloning signal value.

WriteSignal

Writing signal value is also generically defined by a trait -- WriteSignal, consisting of two main functions as well as ReadSignal:

  • notify: notifies current observer (scope) about this signal change.
  • update_untracked: updates signal value non-reactively. Packed with some helpers too:
  • update = update_untracked + notify
  • set = update(|value| *value = new_value)
  • set_untracked = update_untracked(|value| *value = new_value)
  • update_if: notifys only if returned value implementing UpdateNotification trait means value change.

Note

notify and methods using it (update, set) do not track the signal, so if you have an effect where you just update an effect, don't expect it to rerun on its changes.

SignalSetter

Caution

SignalSetters are quite dangerous, and I recommend to avoid using them considering different designs. SignalSetter breaks linear flow of data and makes it hard to figure out where value is actually changed.

Unlike SignalMap, SignalSetter is more complex inside. The idea is following: bind one signal updates to another signal. This is done by creating an effect that gets the first signal value and sets it to the second one, roughly speaking, something like that:

create_effect(move |_| {
    signal1.set(signal2.get_cloned());
});

This is what SignalSetter::set_from method does -- just set one signal with another. But there's a more generic method SignalSetter::setter which allows you to map the value, so you can set a concrete structure field or change source before setting it target.

Trigger

Trigger is a simple wrapper type for Signal, it does not carry any data, instead it is used as subscribe-notify marker. Example:

let trigger = create_trigger();

create_effect(move |_| {
    trigger.track();
    println!("Something's going on...");
});

trigger.notify();

This effect will run each time trigger is notified.

MemoChain

This is kind of clumsy implementation created specifically for rsact_ui styles. MemoChain is a memo along with two mapping functions: first and last. These functions are optionally settable, so none, only one or both can be set. It is created for case appeared in to preserve bottom-top initialization of components. I.e., element is created with default parameters, user sets custom properties and only when element is added to the UI tree (mounted), it may require subscribing to some global state. User sets element styles before it is mounted, so simple style.map(user_logic).map(global_logic) won't work as styles need to be additive and prioritized (default < global < user). MemoChain here is created with default styles for the element, user adds last function to it and first function is set with global styles on mount.

// TODO: Tell about internals? User doesn't work with it as it's a hidden internal implementation of rsact_ui.

Optimizations with MaybeReactive and MaybeSignal

Being an embedded UI framework where each tick counts, rsact is not free to store as much data on heap as we'd like and apply even simple runtime logic such as reactivity tracking. I thought about ways to minimize usage of heap memory and avoid requiring creation of reactive values in places where value is optionally reactive.

Inert

We have reactive values, but it is not generically distinguishable if value is not reactive (though it would be possible with use of "auto traits"). I spent not an addequate amount of time going to this name, but I think it worthed it. You know, chemically inert substances are not reactive 😺.

Inert by itself is just a wrapper around any value, you can create it using Inert::new(value) or by converting any value into it using IntoInert::inert: value.inert().

MaybeReactive

MaybeReactive is a read-only type that is internally one of those: Inert, Signal, Memo, MemoChain or a derived signal. The use of MaybeReactive is to provide a generic way to represent any optionally reactive value, you almost can work with it as with any other readable signal, and don't mind if it's tracked or not. And for users it means that you don't require them to create reactive values where they don't need to. Though, there're some limitations of course:

  • Unlike Signal and Memo, MaybeReactive is not Copy-type as Inert isn't always and derived signals are not.
  • MaybeReactive<T> requires T to be PartialEq so it possible to store Memo.

MaybeReactive implements ReadSignal and SignalMap traits, so you can treat as a signal type. Tracking MaybeReactive value with Inert or derived signal value does nothing, when you call track, get, with, etc. SignalMap works same for reactive types, and mapping Inert becomes MaybeReactive with derived signal function mapper.

MaybeSignal

MaybeSignal is much smarter than MaybeReactive, it is a mutable optionally reactive type, and implements both ReadSignal and WriteSignal. While ReadSignal is similar to MaybeReactive, WriteSignal sets inert in place and for signals writes its value, but SignalSetter does this thing. When SignalSetter::setter is called with inert value MaybeSignal, it just updates MaybeSignal, whereas passing reactive value, MaybeSignal is converted into signal in place, becoming reactive. So you effectively store your plain data, and only in case user want it to depend on some reactive state, it turns into signal. This table shows all cases of using SignalSetter on a MaybeSignal:

Inner type Inert Signal Memo MemoChain Derived signal
Source type
Inert Set once Becomes reactive, subscribes to updates --//-- --//-- Set by derived signal result once
Signal Set once Subscribes to updates, updated reactively --//-- --//-- Set by derived signal result once

Conversions

By conversions I mean both custom specifications like IntoMemo and Rust core Into/From traits. rsact_reactive includes a lot of conversion simplification: Signals are convertible into Memos, functions like derived signals are convertible into Memos and so on.

Type Conversion Output Comment
Signal<T> IntoMemo Memo<T> Same as `signal.map(
Signal<T> IntoSignal Signal<T> Does nothing
Memo<T> IntoMemo Memo<T> Does nothing
Fn() -> T IntoMemo Memo<T> Creates memo from provided function

What is behind runtime?

Reactive runtime stores all values statically, there's no automatic garbage collection, reactive values are manually memory managed.