Core Concepts¶
Understanding the building blocks of ReactiveESP32.
Overview¶
ReactiveESP32 is built on three core primitives:
Signals - Reactive state containers
Computed - Derived state that auto-updates
Effects - Side effects that auto-execute
Together, these create a declarative reactive system where you describe what should happen, not how to update everything.
Signals¶
What are Signals?¶
Signals are reactive containers that hold a value and notify dependents when it changes.
Signal<int> count(0); // Create with initial value
count.set(42); // Update value ⟶ notifies dependents
int value = count.get(); // Read current value
Signal Characteristics¶
Mutable - Can be updated with
.set(),.update()or.mutate()Observable - Tracks who depends on them
Type-safe - Template-based, compile-time type checking
Efficient - Direct dependency tracking, no overhead
When to Use Signals¶
Use Signals for:
User input state (button presses, sensor readings)
Configuration values
Any state that changes over time
// Examples of good Signal usage
Signal<bool> button_pressed(false);
Signal<float> temperature(20.0);
Signal<const char*> wifi_ssid("MyNetwork");
Signal<uint8_t> brightness(128);
Computed Signals¶
What are Computed Signals?¶
Computed signals derive their value from other signals. They automatically re-compute when dependencies change.
Signal<int> width(10);
Signal<int> height(20);
Computed<int> area([]() { return width.get() * height.get(); });
Serial.println(area.get()); // 200
width.set(15);
Serial.println(area.get()); // 300 (auto-updated!)
height.set(30);
Serial.println(area.get()); // 450 (auto-updated!)
Computed Characteristics¶
Read-only - Cannot be set manually
Cached - Doesn’t re-compute unless dependencies change (by default)
Optionally lazy - Can be configured to compute only when accessed
Composable - Can depend on other Computed signals
When to Use Computed¶
Use Computed for:
Derived state calculations
Data transformations
Business logic
Conditional logic
// Examples of good Computed usage
Computed<bool> is_overheating([]() { return temperature.get() > 80.0; });
Computed<float> battery_percent([]() { return (battery_voltage.get() - 3.0) / 1.2 * 100; });
Effects¶
What are Effects?¶
Effects execute side effects when their dependencies change. Unlike Computed, they don’t return a value.
Signal<bool> led_state(false);
Effect<> toggle_led([]() {
digitalWrite(LED_PIN, led_state.get() ? HIGH : LOW);
return nullptr;
});
led_state.set(true); // Effect runs, LED turns on
Key Characteristics¶
Imperative - Executes code, performs actions
Auto-tracking - Automatically tracks dependencies
No return value - Designed for side effects only
Eager or lazy - Can run immediately or on-demand
When to Use¶
Use Effects for:
Hardware control (GPIO, I2C, SPI)
Logging and debugging
Display updates
// Hardware control
Effect<> toggle_relay([]() {
digitalWrite(RELAY_PIN, heater_on.get() ? HIGH : LOW);
return nullptr;
});
// Logging
Effect<> log_temperature([]() {
Serial.printf("Temp: %.1f°C\n", temperature.get());
return nullptr;
});
// Display update
Effect<> update_display([]() {
display.clear();
display.print(message.get());
display.update();
return nullptr;
});
Reactivity Graph¶
How Dependencies Work¶
ReactiveESP32 automatically builds a dependency graph:
Signal<int> a(1);
Signal<int> b(2);
Computed<int> sum([]() { return a.get() + b.get(); });
Computed<int> sum_doubled([]() { return sum.get() * 2; });
Effect<> print_sum_doubled([]() {
Serial.println(sum_doubled.get());
return nullptr;
});
graph LR A[Signal a] --> S[Computed sum] B[Signal b] --> S S --> SD[Computed sum_doubled] SD --> E[Effect print_sum_doubled]
When a changes:
suminvalidatessumre-computessum_doubledinvalidatessum_doubledre-computesprint_sum_doubledruns
Execution Order¶
Updates propagate in topological order (dependencies first).
This ensures consistency - no intermediate states are visible!
Tips for Success¶
Tip
Use .get() inside reactive contexts
Always call .get() on signals inside Computed/Effect to track dependencies:
int value = signal.get();
// Good: Tracked dependency
Computed<int> computed1([]() { return signal.get(); })
// Bad: NOT tracking any dependency
Computed<int> computed2([]() { return value; })
Tip
Use .peek() when you don’t want to track dependencies
If you need to read a signal without creating a dependency, use .peek():
Signal<int> signal_a(0);
Signal<int> signal_b(0);
// computed recomputes only when signal_b changes
Computed<int> computed([]() { return signal_a.peek() + signal_b.get(); })
Warning
Keep reactive objects alive!
Define Signals, Computeds, and Effects at global or static scope to ensure they persist for the program’s lifetime:
// Good: Global scope
Signal<int> global_signal(0);
// Good: Static inside function
void func() {
static Computed<int> static_computed([]() { return global_signal.get() * 2; });
}
// Bad: Local scope (will be destroyed)
void func() {
Effect<> local_effect([]() {
Serial.println(global_signal.get());
return nullptr;
});
}
Warning
Avoid circular dependencies!
Creating circular dependencies between Computeds/Effects can lead to infinite loops.
- Tips to avoid cycles:
Design your reactive graph to be acyclic.
Use
peek()to break cycles if necessary.
Summary¶
Primitive |
Purpose |
Mutable |
Returns Value |
Use Case |
|---|---|---|---|---|
Signal |
Store state |
✅ Yes |
Read via |
User input, config |
Computed |
Derive state |
❌ No |
Read via |
Calculations, logic |
Effect |
Side effects |
N/A |
No return |
Hardware, logging |
The reactive model ensures automatic consistency - you don’t have to manually update dependent values!