DEV Community

tengxgfyrz67s
tengxgfyrz67s

Posted on

Reactive Signals Fundamentals

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);
Enter fullscreen mode Exit fullscreen mode

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()]);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

.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);
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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());
});
Enter fullscreen mode Exit fullscreen mode

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)
    }
);
Enter fullscreen mode Exit fullscreen mode

In this example, full_name automatically updates whenever first_name or last_name changes. The computed! macro takes:

  1. A list of dependent signals — These are the signals that the computation depends on.
  2. 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:

  1. The closure is immediately evaluated to produce the initial value.
  2. Each dependent signal is subscribed to.
  3. When any dependent signal changes, the closure is re-evaluated.
  4. 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.
});
Enter fullscreen mode Exit fullscreen mode

use_window_event

The use_window_event hook attaches an event listener to the window object:

use_window_event("hashchange", move || {
    // Handle hash change
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

In this temperature converter:

  1. celsius is a source signal that stores the Celsius temperature.
  2. fahrenheit is a computed signal that automatically recalculates whenever celsius changes.
  3. The on_input_value handler updates celsius when the user types in the input field.
  4. The computed fahrenheit signal updates automatically, and any UI that depends on it re-renders.

Summary

Reactive signals are the core of euv's state management:

  • use_signal creates 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)