Mejores Prácticas
Guías para usar listen_it efectivamente y evitar errores comunes.
Ciclo de Vida de Cadenas
Attach, Detach y Valores Derivados (v6.0.0+)
Una cadena de operators (source.map(...), a.combineLatest(b, ...), ...) sigue este ciclo de vida:
- Attach - la cadena se suscribe a su(s) fuente(s) cuando se crea (por defecto) o, con
lazy: true, cuando recibe su primer listener - Detach - cuando se elimina el último listener, la cadena se desuscribe de su(s) fuente(s). En una cadena larga esto se propaga en cascada hasta la fuente original
- Re-attach - el siguiente
addListenerse suscribe de nuevo y refresca el valor almacenado antes de registrar el nuevo listener, así no se pierde nada ni se envía ninguna notificación espuria
Mientras una cadena está desconectada, su .value se deriva del valor actual de la fuente al leerlo, así que nunca está obsoleto:
final source = ValueNotifier<int>(1);
final doubled = source.map((x) => x * 2);
void listener() {}
doubled.addListener(listener);
doubled.removeListener(listener); // último listener eliminado -> desconectada
source.value = 5;
print(doubled.value); // 10 ✅ derivado al leer, sin suscripción
doubled.addListener(listener); // reconectada, el valor ya está fresco| Operator | .value mientras está desconectada |
|---|---|
map, select | transformación / selector aplicado al valor actual de la fuente |
where | el valor actual de la fuente si pasa el filtro, si no el último valor que pasó |
debounce, async | valor actual de la fuente |
combineLatest | combinador aplicado a los valores actuales de las fuentes |
mergeWith | último valor recibido (no puede saber qué fuente cambió última) |
Mantén las Transformaciones Puras
Como la transformación puede ejecutarse al leer mientras la cadena está desconectada, las funciones de transformación deben ser puras - sin efectos secundarios, misma entrada → misma salida.
lazy: true
lazy: true solo cambia el paso 1: la primera suscripción ocurre con el primer listener en lugar de en la creación. Antes de eso, .value se deriva al leer exactamente igual que en una cadena desconectada, así que lazy: true es una optimización pura de memoria sin valores obsoletos. Mezclar operators lazy y eager en una cadena está bien.
final source = ValueNotifier<int>(5);
final eager = source.map((x) => x * 2); // conectada al crearse
final lazy = eager.map((x) => x + 1, lazy: true); // se conecta con el primer listener
source.value = 7;
print(eager.value); // 14 ✅
print(lazy.value); // 15 ✅ derivado al leer❌️️ INCORRECTO: Cadenas en Métodos Build
No crees cadenas inline en métodos build:
Inline en Método Build
class BadWidget extends StatelessWidget {
final ValueNotifier<int> source;
BadWidget(this.source, {super.key});
@override
Widget build(BuildContext context) {
// ❌ WRONG: Chain created in build - NEW CHAIN EVERY REBUILD!
final chain = source.map((x) => x * 2); // wasteful, never reused
return Text('${chain.value}');
}
}Inline en ValueListenableBuilder
class BadWidgetValueListenable extends StatelessWidget {
final ValueNotifier<int> source;
BadWidgetValueListenable(this.source, {super.key});
@override
Widget build(BuildContext context) {
// ❌ WRONG: Chain created inline - NEW CHAIN EVERY REBUILD!
return ValueListenableBuilder<int>(
valueListenable: source.map((x) => x * 2), // wasteful, never reused
builder: (context, value, child) => Text('$value'),
);
}
}Por qué esto está mal:
- Se crea un nuevo objeto cadena en cada reconstrucción
- Cada cadena se conecta a la fuente y trabaja hasta que se descarta
- Es un desperdicio y hace que el comportamiento del widget dependa del momento de la reconstrucción
Desde v6.0.0 esto ya no es una fuga de memoria - una cadena descartada se desconecta de la fuente en cuanto pierde sus listeners (o, si nadie la escuchó nunca, con la primera notificación que recibe). Sigue siendo el patrón incorrecto.
✅ CORRECTO: Crear Cadenas Una Vez
Crear cadenas asegurando que se creen solo una vez. Aquí hay tres enfoques seguros:
// ✅ Option 1: StatefulWidget with initState
class MyWidget extends StatefulWidget {
final ValueNotifier<int> source;
const MyWidget(this.source, {super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late final ValueListenable<int> chain;
@override
void initState() {
super.initState();
// ✅ CORRECT: Chain created ONCE in initState
chain = widget.source.map((x) => x * 2);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: chain, // Same object every rebuild
builder: (context, value, child) => Text('$value'),
);
}
}
// ✅ Option 2: watch_it with createOnce
class MyWidgetWithWatchIt extends WatchingWidget {
final ValueNotifier<int> source;
const MyWidgetWithWatchIt(this.source, {super.key});
@override
Widget build(BuildContext context) {
// ✅ CORRECT: createOnce ensures chain created only once
final chain = createOnce(() => source.map((x) => x * 2));
return ValueListenableBuilder<int>(
valueListenable: chain,
builder: (context, value, child) => Text('$value'),
);
}
}
// ✅ Option 3: Put chains in your data layer (RECOMMENDED)
class CounterService {
final source = ValueNotifier<int>(0);
// Chain created once in data layer
late final doubled = source.map((x) => x * 2);
void dispose() {
// Only dispose the source - the chain will be GC'd when service is unreachable
source.dispose();
}
}
class MyWidgetWithService extends StatelessWidget {
const MyWidgetWithService(this.service, {super.key});
final CounterService service;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: service.doubled, // Chain from data layer
builder: (context, value, child) => Text('$value'),
);
}
}Por qué estos funcionan:
- Opción 1: Cadena creada una vez en
initState()(¡no en constructor, que se ejecuta en cada reconstrucción!) - Opción 2:
createOnce()asegura que la cadena se cree solo una vez aunque esté en build - Opción 3: La cadena vive en tu capa de datos (recomendado para apps grandes)
- Todas las opciones reusan el mismo objeto de cadena en cada reconstrucción
No Crear en Constructor
Nunca crear cadenas en un constructor de StatelessWidget o como inicializadores de campo - el constructor se ejecuta en cada reconstrucción, ¡que es el mismo problema que crear en build!
✅ RECOMENDADO: Usar watch_it
El enfoque más seguro es usar watch_it, que cachea el selector y se desuscribe cuando el widget se dispone:
class SafeWatchItWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// ✅ SAFE: watch_it caches selectors by default
// Chain created ONCE on first build, reused on subsequent builds,
// detaches from m.source when the widget is disposed
final value = watchValue((Model m) => m.source.map((x) => x * 2));
return Text('$value');
}
}Por qué watch_it es mejor:
- El
allowObservableChange: falsepredeterminado cachea el selector, así que la cadena se crea solo una vez por instancia de widget - Cuando el widget se dispone, watch_it elimina su listener y la cadena se desconecta de
m.source - Sin gestión manual del ciclo de vida necesaria
- Código limpio y conciso
Disposición
Las Cadenas Normalmente No Necesitan Disposición
Una cadena sin listeners no mantiene ninguna suscripción a su fuente, así que se recolecta en cuanto nada la referencia. Y cuando todo el grafo (fuente + cadena) se vuelve inalcanzable, el GC de Dart lo recolecta independientemente de las suscripciones.
✅ NO necesitas disponer cadenas cuando:
La fuente es propiedad del mismo objeto que la cadena
dartclass CounterService { final source = ValueNotifier<int>(0); late final doubled = source.map((x) => x * 2); void dispose() { source.dispose(); // Solo disponer fuente // La cadena se recolecta automáticamente cuando el servicio se vuelve inalcanzable } }La fuente vive más que la cadena (p. ej. está registrada en get_it) - en cuanto la cadena pierde sus listeners se desconecta de la fuente, así que una cadena descartada no mantiene ocupada a la fuente
dartclass TemporaryViewModel { final globalSource = getIt<ValueNotifier<int>>(); // Fuente de larga vida late final chain = globalSource.map((x) => x * 2); // No se necesita disponer la cadena: cuando el widget que observaba // `chain` se dispone, la cadena pierde su último listener y se desconecta }Usando watch_it - gestión automática del ciclo de vida
Cuándo DEBERÍAS Disponer la Fuente
✅ Siempre disponer la fuente ValueNotifier para:
- Detener que se llamen handlers
- Liberar recursos mantenidos por la fuente
- Seguir gestión apropiada de recursos
class MyService {
final counter = ValueNotifier<int>(0);
late final doubled = counter.map((x) => x * 2);
void dispose() {
counter.dispose(); // Detiene notificaciones y libera recursos
}
}Cuándo Disponer una Cadena Explícitamente
Llama a dispose() en una cadena solo cuando:
- Quieres terminarla explícitamente mientras aún tiene listeners
- Quieres asegurarte de que se cancela un timer de
debouncependiente o una actualizaciónasync
(chain as ChangeNotifier).dispose();Disposición de Suscripciones
Siempre cancelar suscripciones creadas con .listen():
void subscriptionExample() {
final source = ValueNotifier<int>(0);
final chain = source.map((x) => x * 2);
// Create subscription
final subscription = chain.listen((value, _) => print(value));
// Later: cancel subscription when done
subscription.cancel();
// Also dispose the chain itself
if (chain is ChangeNotifier) {
(chain as ChangeNotifier).dispose();
}
}Mejores Prácticas de Colecciones Reactivas
Elegir el Modo de Notificación Correcto
CustomNotifierMode.always (predeterminado):
- Notifica en cada operación, incluso si el valor no cambia
- Usar cuando no hayas sobrescrito el operador
== - Previene confusión de UI al establecer el "mismo" valor
CustomNotifierMode.normal:
- Solo notifica cuando el valor realmente cambia (usa comparación
==) - Usar cuando hayas implementado igualdad apropiada (operador
==) - Más eficiente (menos notificaciones)
CustomNotifierMode.manual:
- Sin notificaciones automáticas
- Debes llamar a
notifyListeners()manualmente - Usar para escenarios de actualización complejos
// Predeterminado: modo always (más seguro)
final items = ListNotifier<String>(data: []);
// Modo normal: solo en cambios
final items = ListNotifier<String>(
data: [],
notificationMode: CustomNotifierMode.normal,
);
// Modo manual: control explícito
final items = ListNotifier<String>(
data: [],
notificationMode: CustomNotifierMode.manual,
);
items.add('item');
items.notifyListeners(); // Notificación explícitaUsar Transacciones para Operaciones Masivas
Agrupar múltiples operaciones en una sola notificación:
final items = ListNotifier<String>(data: []);
// ❌️ SIN transacción: 3 notificaciones
items.add('item1');
items.add('item2');
items.add('item3');
// ✅ CON transacción: 1 notificación
items.startTransAction();
items.add('item1');
items.add('item2');
items.add('item3');
items.endTransAction();Acceder a Valores Inmutables
El getter .value devuelve una vista no modificable:
final items = ListNotifier<String>(data: ['one']);
// ✅ CORRECTO: Usar métodos de colección
items.add('two');
items.removeAt(0);
// ❌️ INCORRECTO: No modificar .value directamente
items.value.add('three'); // ¡Lanza UnsupportedError!Mejores Prácticas de Cadenas de Operators
Mantener Cadenas Legibles
Cadenas largas son poderosas pero pueden volverse difíciles de leer. Considera dividirlas:
// ❌️ Difícil de leer
final result = source
.where((x) => x.isNotEmpty)
.map((x) => x.trim())
.select<int>((x) => x.length)
.debounce(Duration(milliseconds: 300))
.where((len) => len > 3)
.map((len) => len.toString());
// ✅ Mejor: Dividir en pasos lógicos con nombres descriptivos
final nonEmpty = source.where((x) => x.isNotEmpty);
final trimmed = nonEmpty.map((x) => x.trim());
final length = trimmed.select<int>((x) => x.length);
final debounced = length.debounce(Duration(milliseconds: 300));
final filtered = debounced.where((len) => len > 3);
final display = filtered.map((len) => len.toString());Usar select() para Propiedades de Objetos
Al trabajar con objetos, usar select() para reaccionar solo cuando cambien propiedades específicas:
final user = ValueNotifier(User(name: 'John', age: 25));
// ❌️ INEFICIENTE: Notifica en CUALQUIER cambio de usuario
final name = user.map((u) => u.name);
// ✅ MEJOR: Solo notifica cuando el nombre realmente cambia
final name = user.select<String>((u) => u.name);Preferir where() Sobre Lógica Condicional
Filtrar en la fuente en lugar de en el handler:
final input = ValueNotifier<String>('');
// ❌️ Menos eficiente: Todas las actualizaciones llegan al handler
input.listen((value, _) {
if (value.length >= 3) {
search(value);
}
});
// ✅ Mejor: Filtrar actualizaciones antes de que lleguen al handler
input
.where((term) => term.length >= 3)
.listen((value, _) => search(value));Mejores Prácticas de Testing
Testear Cadenas de Operators
test('map operator transforma valores', () {
final source = ValueNotifier<int>(5);
final chain = source.map((x) => x * 2);
expect(chain.value, 10);
source.value = 3;
expect(chain.value, 6);
// Limpiar
(chain as ChangeNotifier).dispose();
});Testear Colecciones Reactivas
test('ListNotifier notifica en add', () {
final items = ListNotifier<String>(data: []);
final notifications = <List<String>>[];
items.listen((list, _) => notifications.add(List.from(list)));
items.add('item1');
items.add('item2');
expect(notifications, [
['item1'],
['item1', 'item2'],
]);
});Limpiar en Tests
Dispón la fuente en los tests; dispón también la cadena si usa debounce() o async() para que ningún timer sobreviva al test:
test('example test', () {
final source = ValueNotifier<int>(0);
final chain = source.debounce(Duration(milliseconds: 100));
// ... código del test ...
// Limpieza
(chain as ChangeNotifier).dispose(); // cancela el timer pendiente
source.dispose();
});Consejos de Rendimiento
Evitar Debouncing Excesivo
Solo aplicar debounce cuando sea necesario (entrada de usuario, cambios rápidos):
// ✅ BUENO: Debounce entrada de usuario
searchTerm
.debounce(Duration(milliseconds: 300))
.listen((term, _) => search(term));
// ❌️ INNECESARIO: Debouncing de actualizaciones infrecuentes
userProfile
.debounce(Duration(seconds: 1)) // El perfil cambia raramente
.listen((profile, _) => updateUI(profile));Usar Transacciones para Colecciones
Agrupar operaciones para reducir sobrecarga de notificaciones:
// ❌️ INEFICIENTE: 1000 notificaciones
for (var i = 0; i < 1000; i++) {
items.add(i);
}
// ✅ EFICIENTE: 1 notificación
items.startTransAction();
for (var i = 0; i < 1000; i++) {
items.add(i);
}
items.endTransAction();Perfilar tus Cadenas
Si el rendimiento es crítico, medir:
final stopwatch = Stopwatch()..start();
chain.listen((value, _) {
print('Update took: ${stopwatch.elapsedMicroseconds}μs');
stopwatch.reset();
});Errores Comunes
1. Crear Cadenas en Build
// ❌️ INCORRECTO: Nueva cadena en cada build
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: source.map((x) => x * 2), // ¡NUEVA CADENA EN CADA RECONSTRUCCIÓN!
builder: (context, value, _) => Text('$value'),
);
}
// ✅ CORRECTO: Usar watch_it o crear la cadena una vez
late final chain = source.map((x) => x * 2);2. Efectos Secundarios en Transformaciones
// ❌️ INCORRECTO: La transformación puede ejecutarse al leer mientras la cadena está desconectada
final logged = source.map((x) {
analytics.track('value', x); // ¡efecto secundario!
return x * 2;
});
// ✅ CORRECTO: Mantén las transformaciones puras, haz los efectos secundarios en listen()
final doubled = source.map((x) => x * 2);
doubled.listen((x, _) => analytics.track('value', x));3. Modificar .value de Colección Directamente
// ❌️ INCORRECTO: Lanza error
items.value.add('new'); // ¡UnsupportedError!
// ✅ CORRECTO: Usar métodos de colección
items.add('new');4. No Usar select() para Objetos
final user = ValueNotifier(User(name: 'John', age: 25));
// ❌️ INEFICIENTE: Notifica incluso cuando el nombre no cambia
user.map((u) => u.name).listen((name, _) => print(name));
// ✅ EFICIENTE: Solo notifica cuando el nombre cambia
user.select<String>((u) => u.name).listen((name, _) => print(name));Resumen
Puntos clave:
- ✅ Nunca crear cadenas en métodos build (o usar watch_it para caché automático)
- ✅ Mantén las funciones de transformación puras - pueden ejecutarse al leer mientras una cadena está desconectada
- ✅ Usar transacciones para operaciones masivas de colecciones
- ✅ Usar select() al reaccionar a propiedades de objetos
- ✅ Preferir where() sobre lógica condicional en handlers
- ✅ Elegir el modo de notificación correcto para colecciones
- ✅ Testear tus cadenas y limpiar en tests
Enfoque recomendado:
- Usar watch_it para widgets (gestión automática del ciclo de vida)
- Usar clases modelo para lógica de negocio (disposición manual)
- Usar transacciones para actualizaciones masivas
- Usar select() para propiedades de objetos