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.
What needs to change
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);
}
};
New listener interfaces
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
API changes summary
| Old Method | New 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 *) |
Compilation errors
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'
Complete migration example
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_;
};
Supporting multiple ESPHome versions
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_;
};
Why this change
The previous CallbackManager<void()> approach had several drawbacks:
- Memory overhead -
std::functionobjects consume memory even when empty (~16 bytes per callback manager) - Always allocated - Callback storage was allocated regardless of whether callbacks were registered
- Indirect calls -
std::functionadds 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::functionindirection - 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)
Finding code that needs updates
# 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/
Reference Pull Request
Questions?
If you have questions about migrating your external component, please ask in:
- ESPHome Discord - #devs channel
- ESPHome GitHub Discussions
Comments
Feel free to leave a comment here to discuss this post wth 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.