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
Section titled “What needs to change”External components must migrate from callback lambdas to implementing listener interfaces:
// Before 2025.12light_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
Section titled “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 stateclass LightTargetStateReachedListener { public: virtual void on_light_target_state_reached() = 0;};
} // namespace esphome::lightAPI changes summary
Section titled “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
Section titled “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
Section titled “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
Section titled “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 {#elseclass 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
Section titled “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
Section titled “Finding code that needs updates”# Find uses of the old callback methodsgrep -rn "add_new_remote_values_callback" your_component/grep -rn "add_new_target_state_reached_callback" your_component/
# Find all light state interactions to reviewgrep -rn "LightState" your_component/Reference Pull Request
Section titled “Reference Pull Request”Questions?
Section titled “Questions?”If you have questions about migrating your external component, please ask in:
- ESPHome Discord - #devs channel
- ESPHome GitHub Discussions
Copyright © 2026 ESPHome - A project from the Open Home Foundation
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.