Project Code:https://github.com/euv-dev/euv
What are Reactive Signals?
Reactive signals are the state management backbone of euv. A signal is a reactive value that notifies its subscribers whenever it changes. When you read a signal's value in a computation or a component, euv automatically tracks that dependency. When the signal's value changes, all dependent computations and components are automatically re-evaluated.
This model is known as fine-grained reactivity, and it's what makes euv fast — only the parts of the UI that actually depend on a changed value get updated.
Creating Signals with use_signal
The primary way to create a signal in euv is the use_signal hook. It takes an initializer function that produces the signal's starting value:
let count: Signal<i32> = use_signal(|| 0);
In this example, count is a Signal<i32> — a signal holding an i32 value, initialized to 0. The initializer function || 0 is called once when the signal is created.
You can create signals of any type that satisfies the trait bounds:
let name: Signal<String> = use_signal(|| "Alice".to_string());
let is_visible: Signal<bool> = use_signal(|| true);
let items: Signal<Vec<String>> = use_signal(|| vec!["item1".to_string(), "item2".to_string()]);
Reading and Writing Signal Values
Getting a Value
To read the current value of a signal, use the .get() method:
let count: Signal<i32> = use_signal(|| 0);
let value: i32 = count.get();
.get() returns a snapshot of the signal's current value. When called inside a component, it also registers the signal as a dependency of the current render context.
Setting a Value
To update a signal's value, use the .set() method:
let count: Signal<i32> = use_signal(|| 0);
count.set(42);
When .set() is called, the signal checks whether the new value is actually different from the old one (using PartialEq). If it is, the signal notifies all its subscribers, triggering re-renders of any components that depend on it.
Signal Trait Bounds
Signals have specific type requirements. The value type T must implement:
-
Clone— Because the signal needs to produce copies of its value when read. -
PartialEq— Because the signal needs to compare old and new values to avoid unnecessary updates. -
'static— Because the signal may be stored for the lifetime of the application.
// This works: i32 implements Clone, PartialEq, and 'static
let count: Signal<i32> = use_signal(|| 0);
// This works: String implements Clone, PartialEq, and 'static
let name: Signal<String> = use_signal(|| "default".to_string());
Signal is Copy
An important property of Signal<T> is that it implements Copy. This means you can pass signals around by value without explicitly cloning them:
let count: Signal<i32> = use_signal(|| 0);
// count is copied, not moved
let reference = count;
// Both count and reference point to the same signal
count.set(10);
let value = reference.get(); // value is 10
This makes it easy to share signals across closures and component functions without explicit reference counting.
Available Signal Methods
The Signal type provides the following methods:
| Method | Description |
|---|---|
create(initializer) |
Creates a new signal with the given initializer function |
default() |
Creates a signal with the default value of type T
|
get() |
Returns the current value of the signal |
set(value) |
Sets a new value for the signal |
subscribe(callback) |
Subscribes a callback to be called when the signal changes |
Using subscribe
The subscribe method lets you register a callback that fires whenever the signal's value changes:
let count: Signal<i32> = use_signal(|| 0);
count.subscribe(move |value: i32| {
// This runs every time count changes
});
This is useful for side effects that should happen in response to state changes, such as logging, analytics, or triggering other signals.
Batching Updates
When you need to update multiple signals at once, you can use the batch function to group the updates together. This prevents unnecessary intermediate renders:
let count: Signal<i32> = use_signal(|| 0);
let name: Signal<String> = use_signal(|| "default".to_string());
batch(|| {
count.set(1);
name.set("updated".to_string());
});
Without batch, each .set() call would trigger a separate render. With batch, the updates are deferred and applied together, resulting in a single render pass. This is particularly important when you have multiple signals that need to be updated in response to a single user action.
Derived Signals with computed!
A derived signal (also called a computed signal) is a signal whose value is computed from other signals. You create one using the computed! macro:
let first_name: Signal<String> = use_signal(|| "John".to_string());
let last_name: Signal<String> = use_signal(|| "Doe".to_string());
let full_name: Signal<String> = computed!(first_name, last_name,
|first: String, last: String| -> String {
format!("{} {}", first, last)
}
);
In this example, full_name automatically updates whenever first_name or last_name changes. The computed! macro takes:
- A list of dependent signals — These are the signals that the computation depends on.
- A closure — This closure receives the current values of the dependent signals and returns the computed result.
The closure's parameters are typed, so the compiler ensures you're using the correct types. The return type is also explicitly specified with -> Type.
How computed! Works Under the Hood
When you create a computed signal:
- The closure is immediately evaluated to produce the initial value.
- Each dependent signal is subscribed to.
- When any dependent signal changes, the closure is re-evaluated.
- If the new result differs from the old one (using
PartialEq), the computed signal notifies its own subscribers.
This creates a reactive chain: when a source signal changes, computed signals that depend on it are automatically updated, which in turn triggers updates in any components or other computed signals that depend on them.
Other Utility Hooks
use_cleanup
The use_cleanup hook lets you register a cleanup function that runs when the component is unmounted:
use_cleanup(move || {
// Cleanup code: remove listeners, clear resources, etc.
});
use_window_event
The use_window_event hook attaches an event listener to the window object:
use_window_event("hashchange", move || {
// Handle hash change
});
use_interval
The use_interval hook sets up a recurring timer:
let handle: IntervalHandle = use_interval(1000, move || {
// This callback runs every 1000 milliseconds
});
handle.clear();
The IntervalHandle returned by use_interval has a .clear() method to stop the timer.
Putting It All Together
Here's a more complete example that demonstrates signals, computed values, and batching:
use euv::*;
fn app() -> VirtualNode {
let celsius: Signal<f64> = use_signal(|| 0.0);
let fahrenheit: Signal<f64> = computed!(celsius,
|c: f64| -> f64 {
c * 9.0 / 5.0 + 32.0
}
);
html! {
div {
h1 { "Temperature Converter" }
input {
oninput: on_input_value(celsius)
}
p { "Fahrenheit" }
}
}
}
mount("#app", app);
In this temperature converter:
-
celsiusis a source signal that stores the Celsius temperature. -
fahrenheitis a computed signal that automatically recalculates whenevercelsiuschanges. - The
on_input_valuehandler updatescelsiuswhen the user types in the input field. - The computed
fahrenheitsignal updates automatically, and any UI that depends on it re-renders.
Summary
Reactive signals are the core of euv's state management:
-
use_signalcreates a reactive value with automatic dependency tracking. -
.get()and.set()read and write signal values. -
batch()groups multiple signal updates into a single render pass. -
computed!creates derived signals that automatically update when their dependencies change. -
subscribe()registers callbacks for signal changes. -
Signal values must implement
Clone + PartialEq + 'static. -
Signal itself implements
Copy, making it easy to share across closures.
With these primitives, you can build complex, reactive UIs with minimal boilerplate and maximum performance.
Project Code:https://github.com/euv-dev/euv
Top comments (0)