-
Notifications
You must be signed in to change notification settings - Fork 0
rsact_reactive
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
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 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.
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
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
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.
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)
: mapsT
intoU
, producingMemo<U>
-
map_cloned<U>(f)
: same asmap
but mapping function acceptsT
instead of&T
, cloning signal value.
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
:notify
s only if returned value implementingUpdateNotification
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.
Caution
SignalSetter
s 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
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.
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
.
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.
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
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
andMemo
,MaybeReactive
is notCopy
-type asInert
isn't always and derived signals are not. -
MaybeReactive<T>
requiresT
to bePartialEq
so it possible to storeMemo
.
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
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 |
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 Memo
s, functions like derived signals are convertible into Memo
s 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 |
Reactive runtime stores all values statically, there's no automatic garbage collection, reactive values are manually memory managed.