DEV Community

tengxgfyrz67s
tengxgfyrz67s

Posted on

computed-and-watch-macros

Computed and Watch Macros in euv

Project Code:https://github.com/euv-dev/euv

euv is a Rust + WASM frontend UI framework that brings reactive programming to the browser. Two of its most powerful macros — computed! and watch! — enable derived state and side-effect management with minimal boilerplate. This article explores both macros in depth, covering multi-signal linkage, internal batching, and practical patterns.

Reactive Signals: A Quick Refresher

Before diving into computed! and watch!, let's revisit the foundation: Signal<T>.

let count: Signal<i32> = use_signal(|| 0);
let value: i32 = count.get();
count.set(42);
Enter fullscreen mode Exit fullscreen mode

A Signal is a reactive container. It supports create, default, get, set, and subscribe. The type T must satisfy Clone + PartialEq + 'static, and Signal<T> itself implements Copy, making it effortless to pass around. For static contexts, SignalCell provides similar capabilities.

Signals are the atoms of reactivity in euv. Every piece of reactive data — from a simple counter to a complex derived value — is built on top of Signal.

The computed! Macro: Derived Signals

The computed! macro creates a derived signal — a Signal whose value is automatically recalculated whenever one of its dependencies changes.

Basic Usage

let full_name: Signal<String> = computed!(first_name, last_name, |first: String, last: String| -> String {
    format!("{} {}", first, last)
});
Enter fullscreen mode Exit fullscreen mode

Here, full_name depends on first_name and last_name. Whenever either signal changes, full_name is recomputed. The closure receives the current values of all listed signals as arguments, and its return type becomes the T of the resulting Signal<String>.

The syntax is:

let signal = computed!(dep1, dep2, ..., |val1, val2, ...| -> ReturnType { body });
Enter fullscreen mode Exit fullscreen mode

How It Works Internally

When computed! is invoked, euv:

  1. Reads the current values of all dependency signals.
  2. Evaluates the closure to produce the initial value.
  3. Subscribes to each dependency signal.
  4. When any dependency changes, re-evaluates the closure and updates the derived signal.

This means computed! signals are lazy on read, eager on change. The closure is not re-executed until a dependency actually changes, but when it does, the update propagates immediately to any subscribers.

Multi-Signal Dependencies

You can list as many dependency signals as needed. Each signal's current value is passed as a separate argument to the closure:

let full_name: Signal<String> = computed!(first_name, last_name, |first: String, last: String| -> String {
    format!("{} {}", first, last)
});
Enter fullscreen mode Exit fullscreen mode

The dependency signals can be of different types. The closure's parameter types must match the T of each Signal<T> in order.

Chaining Computed Signals

Because computed! returns a Signal, you can chain derivations:

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)
});
let greeting: Signal<String> = computed!(full_name, |name: String| -> String {
    format!("Hello, {}!", name)
});
Enter fullscreen mode Exit fullscreen mode

When first_name or last_name changes, full_name updates, which in turn triggers greeting to update. The dependency graph is automatically managed.

The watch! Macro: Signal Linkage and Side Effects

While computed! creates derived values, watch! creates side effects — actions that run when signals change. It's the bridge between your reactive state and the outside world (DOM updates, network requests, logging, etc.).

Basic Usage

watch!(celsius, |celsius_value: f64| {
    fahrenheit.set(celsius_value * 9.0 / 5.0 + 32.0);
});
Enter fullscreen mode Exit fullscreen mode

This watches celsius and, whenever it changes, sets fahrenheit to the converted value. The closure receives the current value of the watched signal.

Multi-Signal Watch

watch! supports watching multiple signals simultaneously:

watch!(red, green, blue, |r, g, b| {
    hex_color.set(format!("#{:02x}{:02x}{:02x}", r.clamp(0,255), g.clamp(0,255), b.clamp(0,255)));
});
Enter fullscreen mode Exit fullscreen mode

When any of red, green, or blue changes, the closure fires with the latest values of all three. This is extremely useful for scenarios where multiple inputs contribute to a single output — like a color picker that combines RGB channels into a hex string.

Watch vs Computed: When to Use Which

Aspect computed! watch!
Purpose Derive a value Trigger side effects
Returns Signal<T> Subscription handle
Use case Data transformation State synchronization, I/O

Use computed! when you need a derived value that other parts of your code will read. Use watch! when you need to react to changes — for example, updating another signal, making a network call, or logging.

Practical Pattern: Bidirectional Conversion

A classic use case is unit conversion, where changing either value should update the other:

let celsius: Signal<f64> = use_signal(|| 0.0);
let fahrenheit: Signal<f64> = use_signal(|| 32.0);

watch!(celsius, |celsius_value: f64| {
    fahrenheit.set(celsius_value * 9.0 / 5.0 + 32.0);
});

watch!(fahrenheit, |fahrenheit_value: f64| {
    celsius.set((fahrenheit_value - 32.0) * 5.0 / 9.0);
});
Enter fullscreen mode Exit fullscreen mode

Note: In a real application, you'd need to be careful about infinite loops when two watches update each other. Consider using a flag or batch to coordinate updates.

The batch Macro: Coordinated Updates

When you need to update multiple signals at once — especially in the context of watch! — the batch macro ensures efficient, coordinated updates:

batch(|| {
    count.set(1);
    name.set("updated".to_string());
});
Enter fullscreen mode Exit fullscreen mode

Inside a batch, all signal mutations are collected and applied together. This prevents intermediate states from triggering unnecessary re-renders or watch callbacks. It's particularly useful when:

  • You need to update several signals in response to a single user action.
  • You want to avoid transient inconsistent states.
  • You're inside a watch! callback and need to update multiple dependent signals.

Batch with Watch

watch!(red, green, blue, |r, g, b| {
    batch(|| {
        hex_color.set(format!("#{:02x}{:02x}{:02x}", r.clamp(0,255), g.clamp(0,255), b.clamp(0,255)));
        color_name.set(lookup_name(r, g, b));
    });
});
Enter fullscreen mode Exit fullscreen mode

By wrapping multiple set calls in batch, you ensure that any downstream subscribers (including the DOM renderer) are only notified once, after all values have been updated.

Internal Batch Mechanism

Both computed! and watch! leverage euv's internal batching system. When a signal changes:

  1. The change is recorded but not immediately propagated.
  2. All dependent computed! signals are marked as stale.
  3. All watch! callbacks are queued.
  4. After the current execution context completes, the batch flushes: computed signals are re-evaluated, and watch callbacks fire.

This means that even if you set a signal multiple times in a single synchronous block, the derived values and side effects only update once. The framework automatically batches updates for efficiency.

You can also use batch explicitly when you need manual control over this process:

batch(|| {
    count.set(1);
    name.set("updated".to_string());
});
Enter fullscreen mode Exit fullscreen mode

Practical Examples

Example 1: Shopping Cart Total

let price: Signal<f64> = use_signal(|| 29.99);
let quantity: Signal<i32> = use_signal(|| 2);
let tax_rate: Signal<f64> = use_signal(|| 0.08);

let subtotal: Signal<f64> = computed!(price, quantity, |p: f64, q: i32| -> f64 {
    p * q as f64
});

let tax: Signal<f64> = computed!(subtotal, tax_rate, |s: f64, r: f64| -> f64 {
    s * r
});

let total: Signal<f64> = computed!(subtotal, tax, |s: f64, t: f64| -> f64 {
    s + t
});

watch!(total, |t: f64| {
    println!("Total updated: ${:.2}", t);
});
Enter fullscreen mode Exit fullscreen mode

Example 2: Form Validation

let email: Signal<String> = use_signal(|| "".to_string());
let password: Signal<String> = use_signal(|| "".to_string());

let email_valid: Signal<bool> = computed!(email, |e: String| -> bool {
    e.contains('@')
});

let password_valid: Signal<bool> = computed!(password, |p: String| -> bool {
    p.len() >= 8
});

let form_valid: Signal<bool> = computed!(email_valid, password_valid, |e: bool, p: bool| -> bool {
    e && p
});

watch!(form_valid, |valid: bool| {
    submit_button_disabled.set(!valid);
});
Enter fullscreen mode Exit fullscreen mode

Example 3: Temperature Converter with Watch

let celsius: Signal<f64> = use_signal(|| 0.0);
let fahrenheit: Signal<f64> = use_signal(|| 32.0);

watch!(celsius, |celsius_value: f64| {
    fahrenheit.set(celsius_value * 9.0 / 5.0 + 32.0);
});
Enter fullscreen mode Exit fullscreen mode

Cleanup and Lifecycle

When working with signals in components, remember to clean up resources using use_cleanup:

use_cleanup(move || {
    if let Some(h) = handle.get() {
        h.clear();
    }
});
Enter fullscreen mode Exit fullscreen mode

This is especially important for intervals and event listeners that would otherwise leak:

let handle: IntervalHandle = use_interval(1000, move || {
    /* callback */
});
// Later, to stop:
handle.clear();
Enter fullscreen mode Exit fullscreen mode

Summary

  • computed! creates derived signals that automatically update when dependencies change. Perfect for data transformation and derived state.
  • watch! creates side-effect subscriptions that fire when signals change. Ideal for state synchronization, logging, and triggering actions.
  • batch coordinates multiple signal updates, preventing intermediate states and unnecessary re-renders.
  • Both macros leverage euv's internal batching mechanism for efficient updates.
  • Use computed! for derived values, watch! for side effects, and batch for coordinated multi-signal updates.

Together, these three primitives form the backbone of reactive state management in euv applications. Mastering them enables you to build complex, responsive UIs with clean, declarative code.


Project Code:https://github.com/euv-dev/euv

Top comments (0)