Skip to content

Light State Callbacks: Listener Pattern Migration

The light component’s callback system has been refactored to use a listener interface pattern with lazy allocation. This replaces the previous CallbackManager<void()> approach with explicit listener interfaces that only allocate memory when callbacks are actually registered.

This is a breaking change for external components that register light state callbacks in ESPHome 2025.12.0 and later.

External components must migrate from callback lambdas to implementing listener interfaces:

// Before 2025.12
light_state->add_new_remote_values_callback([this]() {
this->on_light_update();
});
light_state->add_new_target_state_reached_callback([this]() {
this->on_target_reached();
});
// After 2025.12
// 1. Inherit from the listener interface(s)
class MyComponent : public light::LightRemoteValuesListener,
public light::LightTargetStateReachedListener {
// 2. Implement the required methods
void on_light_remote_values_update() override {
this->on_light_update();
}
void on_light_target_state_reached() override {
this->on_target_reached();
}
void setup() override {
// 3. Register as a listener
light_state->add_remote_values_listener(this);
light_state->add_target_state_reached_listener(this);
}
};

Two new abstract base classes are available in the esphome::light namespace:

namespace esphome::light {
// For notifications when light values change remotely (e.g., from API/MQTT)
class LightRemoteValuesListener {
public:
virtual void on_light_remote_values_update() = 0;
};
// For notifications when light reaches its target state
class LightTargetStateReachedListener {
public:
virtual void on_light_target_state_reached() = 0;
};
} // namespace esphome::light
Old MethodNew Method
add_new_remote_values_callback(std::function<void()> &&)add_remote_values_listener(LightRemoteValuesListener *)
add_new_target_state_reached_callback(std::function<void()> &&)add_target_state_reached_listener(LightTargetStateReachedListener *)

External components using the old callback API will fail with:

error: 'class esphome::light::LightState' has no member named 'add_new_remote_values_callback'

or:

error: 'class esphome::light::LightState' has no member named 'add_new_target_state_reached_callback'

Here’s an example based on a HomeKit light integration that synchronizes state:

Before:

class HomeKitLight {
public:
void setup(light::LightState *light) {
this->light_ = light;
light->add_new_target_state_reached_callback([this]() {
this->on_entity_update();
});
}
protected:
void on_entity_update() {
// Sync state to HomeKit
}
light::LightState *light_;
};

After:

#include "esphome/components/light/light_state.h"
class HomeKitLight : public light::LightTargetStateReachedListener {
public:
void setup(light::LightState *light) {
this->light_ = light;
light->add_target_state_reached_listener(this);
}
void on_light_target_state_reached() override {
this->on_entity_update();
}
protected:
void on_entity_update() {
// Sync state to HomeKit
}
light::LightState *light_;
};

If your external component needs to support both old and new ESPHome versions:

#include "esphome/components/light/light_state.h"
#if ESPHOME_VERSION_CODE >= VERSION_CODE(2025, 12, 0)
class MyLightComponent : public light::LightTargetStateReachedListener {
#else
class MyLightComponent {
#endif
public:
void setup(light::LightState *light) {
this->light_ = light;
#if ESPHOME_VERSION_CODE >= VERSION_CODE(2025, 12, 0)
light->add_target_state_reached_listener(this);
#else
light->add_new_target_state_reached_callback([this]() {
this->on_light_target_state_reached();
});
#endif
}
#if ESPHOME_VERSION_CODE >= VERSION_CODE(2025, 12, 0)
void on_light_target_state_reached() override {
#else
void on_light_target_state_reached() {
#endif
// Handle state change
}
protected:
light::LightState *light_;
};

The previous CallbackManager<void()> approach had several drawbacks:

  1. Memory overhead - std::function objects consume memory even when empty (~16 bytes per callback manager)
  2. Always allocated - Callback storage was allocated regardless of whether callbacks were registered
  3. Indirect calls - std::function adds indirection overhead for every callback invocation

The new listener pattern provides:

  • Lazy allocation - Memory is only allocated when listeners are actually registered
  • Direct virtual calls - More efficient than std::function indirection
  • Explicit interfaces - Clear contracts for what notifications are available
  • Memory savings - ~16 bytes saved per light when callbacks are unused (null pointer vs. empty CallbackManager)
Terminal window
# Find uses of the old callback methods
grep -rn "add_new_remote_values_callback" your_component/
grep -rn "add_new_target_state_reached_callback" your_component/
# Find all light state interactions to review
grep -rn "LightState" your_component/

If you have questions about migrating your external component, please ask in:

Comments

Feel free to leave a comment here to discuss this post with others. You can ask questions, share your experience, or suggest improvements. If you have a question about a specific feature or issue, please consider using the ESPHome Discord. Stick to English and follow ESPHome's code of conduct. These comments exist on a discussion on GitHub, so you can also comment there directly if you prefer.