Core Concepts

Understanding the building blocks of ReactiveESP32.

Overview

ReactiveESP32 is built on three core primitives:

  1. Signals - Reactive state containers

  2. Computed - Derived state that auto-updates

  3. 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:

  1. sum invalidates

  2. sum re-computes

  3. sum_doubled invalidates

  4. sum_doubled re-computes

  5. print_sum_doubled runs

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 .get()

User input, config

Computed

Derive state

❌ No

Read via .get()

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!

Next Steps

  • Core - Core API reference

  • Examples - Examples using ReactiveESP32