Flor Signal Reactive System
Flor framework has a built-in lightweight reactive signal system. This signal system is inspired by Floem, referencing its API design, but completely independently implemented by us. Unlike Floem, because of Flor's design goals, Flor Signal naturally supports cross-thread use, signals can be safely read, written and passed in multi-threaded environments.
This article mainly explains how to use Signal. For complete function signatures and trait lists, see Signal API Reference.
Creating Signals
Signal usage entry points are basically all under flor::signal. First remember these create_* APIs, can complete most scenarios:
Value Signal
Most commonly used is create_signal. It returns a RwSignal<T>, can read and write.
If reading and writing will be handed to different code, using create_rw_signal can directly get a pair of read-write handles when creating:
Can also first create RwSignal<T>, then split or derive where needed:
Constant Signal
Constant signal is a practical capability: it allows API to simultaneously accept fixed values and reactive values. For example, view property can pass "Hello", or pass a closure reading signal.
ConstSignal<T> doesn't enter global runtime, doesn't establish subscription, destroy() is also empty operation. It just wraps a normal value into a reader implementing Read<T>.
Most times don't need to directly write ConstSignal::new, using IntoRead is more natural:
IntoRead<T> already covers common basic types, String, &str, &String, ConstSignal<T>, ReadSignal<T> and RwSignal<T>. This lets API unify "fixed value" and "reactive value" into reader processing.
Reading and Writing
Clone Read
get() and try_get() will clone value, therefore requires T: Clone + 'static.
get() panics when signal doesn't exist or type mismatches; try_get() returns None when signal doesn't exist.
Reference Read
If value is large, don't want to clone, can use get_ref() or try_get_ref(). They return SignalRef<'_, T>, can be used like &T.
SignalRef holds underlying read lock. Before guard is released, don't call set(), update() and other operations requiring write lock on same signal in same thread, otherwise may deadlock.
Writing
set() will replace entire value, update() will modify value in-place.
If signal may have been destroyed, use try_set() and try_update():
Reactive Update
create_effect
create_effect creates basic side effect. Closure will immediately execute once, and receive value returned by previous execution.
First execution previous is None. Subsequent triggered by signal update, previous is value returned by previous closure.
create_updater
create_updater is more suitable for UI and derived state binding. It first executes compute to get initial value and returns; subsequent dependency changes re-execute compute, then give new value to on_change.
Note: on_change won't be called during initial calculation, only called after dependency update.
create_updater_with_id
Normal business code usually just uses create_updater. create_updater_with_id is mainly for framework internal use, it returns (Id, R), where Id is effect id, R is initial value.
Framework internally uses it to delay some UI effects until after view activation then enqueue, for example class, layout, transform and other builders. Reason for delay is because during layout assembly, view's ViewId may not have been mounted to window and parent view. After view activation, putting effect id into update queue can let these UI updates execute in complete view relationship.
List Signal
List signal saves Vec<T>, and allocates independent row-level Id for each list element. Structure changes subscribe to list Id; reading or modifying some element will subscribe or trigger that element's own row-level Id.
Creation and Basic Operations
Reading List
len() returns Option<usize>, returns None after signal destroyed; len_or_zero() treats non-existent list as empty list. get(index) panics when list doesn't exist or out of bounds; try_get(index) returns None when list doesn't exist or out of bounds.
No-Clone Read
When need to avoid clone, can use for_each_ref() or try_borrow().
for_each_ref() will subscribe to list structure and row-level signals traversed. try_borrow() currently only subscribes to list structure; if effect needs to re-execute after element value is set(index, ...) or update(index, ...), prefer using get(), to_vec(), for_each_ref() or contains().
Safe Write Version
All list write methods have corresponding safe versions:
Structure changes trigger list Id; element value changes only trigger that element's row-level Id. Therefore, effect only reading len() won't re-execute because of set(index, ...); effect reading some element, to_vec() or for_each_ref() will be sensitive to corresponding element value changes.
Batch Update
batch will open batch processing mode in current thread. Signal writes in closure won't immediately add each change to update queue, instead collect changed signal Id, after closure ends uniformly enqueue and deduplicate.
batch deduplication granularity is signal Id. Same signal written multiple times in one batch, will only enqueue once for this signal after closure ends; so above a.set(1) and a.set(2) will only trigger one a subscription update, subscriber sees value after last write. Different signals will still separately enqueue, for example a and b will each trigger their own subscription updates.
batch is thread-local, only affects current thread. Signal writes in other threads won't enter current thread's batch processing collection. If closure panics, batch will restore batch processing state, then continue propagating panic outward.
Integration with UI Views
This chapter belongs to "View Development" content. If don't need to develop views, quick understanding is enough.
Flor UI views usually bind reactive values through create_updater.
Take Label::new pattern as example:
User side usually passes closure reading signal:
Workflow:
create_updaterfirst executescomputeonce, gets view initial value.- When reading signal in
compute, current effect id is recorded in thread-localSCOPE. Read::track()orListRead::track()subscribes current effect to read signal.- When signal writes, runtime adds corresponding signal id to update queue.
- Flor event loop calls
RUNTIME.execute_update_queue(), runs subscribed effects. on_changecallsViewId::update_stateor other UI update entry points.
Usage Suggestions
Prefer passing reactive values through closures:
Don't pass title.get() result directly to API needing reactive update. That only reads a normal value once.
Use ReadSignal<T>, WriteSignal<T> to express business intent:
Avoid writing to signal you depend on in effect:
This kind of code will repeatedly trigger itself in subsequent updates, easily forming infinite updates.
When holding SignalRef or ListRef, first release reference guard, then write to same signal.
For complete function signatures and trait lists, see Signal API Reference.
Design Explanation
Lifetime
All value signals and list signals are globally alive. RwSignal<T>, ReadSignal<T>, WriteSignal<T>, RwListSignal<T>, ReadListSignal<T>, WriteListSignal<T> are all Copy handles, copying handle won't copy underlying value, won't change lifetime.
Global alive is Flor's tradeoff to keep signal handles Copy usable: after signal created, it lives in global runtime, dropping some handle won't release underlying data. Flor is not only suitable for large even super large GUI, also suitable for small tool programs; in lightweight programs, Flor's compiled size performance is also one of core design goals. For small tools, simple windows, stable interface structure, window lifetime close to process lifetime programs, letting signals naturally be recycled with process end is usually one of recommended writing styles, memory and performance impact is small.
destroy() is more suitable for dynamic resources with clear ending lifetime, for example windows, pages, panels that can be repeatedly created and destroyed, or temporary signals with large quantity in heavy, super heavy GUI. After using destroy(), then combine with try_get(), try_set(), try_update() to handle possibly invalid handles.
When list signal is destroyed, it will simultaneously destroy all row-level signals inside it.
Read-Write Semantics
Flor's read-write capability is provided through traits. ReadSignal<T> only implements Read<T>, so under normal type constraints can't call set() or update(); WriteSignal<T> only implements Write<T>, so can't call get().
Read-write split expresses business semantics, not security boundary or permission isolation. This sentence doesn't mean reader has writer's permission: ReadSignal<T> itself has no write methods, when passed by Read<T> constraint can only read; WriteSignal<T> itself has no read methods, when passed by Write<T> constraint can only write. Read is read, write is write, can maintain clarity in business code and source code search.
Therefore, feel free to use corresponding signal types to express business access boundaries: places only needing read pass ReadSignal<T>, places only needing write pass WriteSignal<T>, when need both then pass RwSignal<T>. Such split can make API, call points and code reading all clearer.
Meanwhile, Flor won't let this semantic split hold back usage. When you really need write, code will appear as_write(), split() or writer parameter, reader can clearly see here got new access capability; under normal paths, reader still won't have write permission.
Debug Label
Can set labels for read-write signals, convenient for debugging and tracing.
Labels are only written to runtime in debug_assertions or when signal-tracing feature enabled. Currently this part mainly provides runtime-side label recording, complete visual debugging experience still needs to wait for devtools related capability to mature; therefore it's more suitable as foundation capability for subsequent tracing and debugging tools, rather than already completed user-side debugging panel.
Deep: Runtime Mechanism
This part is for understanding Signal's internal behavior; normal usage doesn't need to master these details first.
RUNTIME saves following core states:
Dependency tracking relies on thread-local SCOPE:
- Before effect execution, write current effect id to
SCOPE. get(),get_ref(),ListReadread methods will calltrack().track()readsSCOPE, subscribes current effect to corresponding signal id.- When writing signal, runtime adds written signal id to update queue.
- Event loop executes update queue, finds effects subscribing to that signal and runs.
batch uses thread-local BATCH to collect signal ids. After batch processing ends, these ids are uniformly added to global update queue.
Current implementation won't actively clear old dependencies before each effect re-execution. For dynamic branches where dependency set frequently changes, should try to keep read range stable, avoid long-term accumulating no longer needed subscriptions.

