Observando Múltiples Valores
Cuando tu widget necesita datos de múltiples ValueListenables, tienes varias estrategias para elegir. Cada enfoque tiene diferentes compromisos en términos de claridad de código, frecuencia de reconstrucción y rendimiento.
Los Dos Enfoques Principales
Enfoque 1: Llamadas Watch Separadas
Observa cada valor por separado - el widget se reconstruye cuando CUALQUIER valor cambia:
class UserProfileWidget extends WatchingWidget {
const UserProfileWidget({super.key});
@override
Widget build(BuildContext context) {
// Watch three separate values - widget rebuilds when ANY changes
final name = watchValue((SimpleUserManager m) => m.name);
final email = watchValue((SimpleUserManager m) => m.email);
final avatarUrl = watchValue((SimpleUserManager m) => m.avatarUrl);
return Column(
children: [
if (avatarUrl.isNotEmpty)
CircleAvatar(backgroundImage: NetworkImage(avatarUrl)),
Text(name,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(email, style: const TextStyle(color: Colors.grey)),
],
);
}
}Cuándo usar:
- ✅ Los valores no están relacionados
- ✅ Lógica de UI simple
- ✅ Todos los valores son necesarios para el renderizado
Comportamiento de reconstrucción: El widget se reconstruye cuando cualquiera de los tres valores cambia.
Enfoque 2: Combinación en la Capa de Datos
Combina múltiples valores usando operators de listen_it en tu manager - el widget se reconstruye solo cuando el resultado combinado cambia:
class FormManager {
final email = ValueNotifier<String>('');
final password = ValueNotifier<String>('');
// Combine email and password validation in the DATA LAYER
late final isValid = email.combineLatest(
password,
(emailValue, passwordValue) {
final emailValid = emailValue.contains('@') && emailValue.length > 3;
final passwordValid = passwordValue.length >= 8;
return emailValid && passwordValid;
},
);
}class FormWidget extends WatchingWidget {
const FormWidget({super.key});
@override
Widget build(BuildContext context) {
final manager = di<FormManager>();
// Watch the COMBINED result - rebuilds only when validation state changes
final isValid = watchValue((FormManager m) => m.isValid);
return Column(
children: [
TextField(
decoration: const InputDecoration(labelText: 'Email'),
onChanged: (value) => manager.email.value = value,
),
TextField(
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
onChanged: (value) => manager.password.value = value,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: isValid ? () {} : null,
child: Text(isValid ? 'Submit' : 'Fill form correctly'),
),
],
);
}
}Cuándo usar:
- ✅ Los valores están relacionados/dependientes
- ✅ Necesitas un resultado computado
- ✅ Quieres reducir reconstrucciones
- ✅ Lógica de validación compleja
Comportamiento de reconstrucción: El widget se reconstruye solo cuando isValid cambia, no cuando los valores individuales de email o password cambian (a menos que afecte la validez).
Patrón: Validación de Formularios con combineLatest
Uno de los casos de uso más comunes para combinar valores es la validación de formularios:
El Problema: Quieres habilitar un botón de envío solo cuando TODOS los campos del formulario son válidos.
Sin combinar: El widget se reconstruye en cada pulsación de tecla en cualquier campo, incluso si el estado de validación no cambia.
Con combinación: El widget se reconstruye solo cuando el estado general de validación cambia (inválido ↔ válido o viceversa).
Ver el ejemplo de formulario anterior para el patrón completo.
Patrón: Combinando 3+ Valores
Para más de 2 valores, usa combineLatest3, combineLatest4, hasta combineLatest6:
class UserProfileManager {
final firstName = ValueNotifier<String>('John');
final lastName = ValueNotifier<String>('Doe');
final avatarUrl = ValueNotifier<String>('https://example.com/avatar.png');
// Combine 3 values into a computed display object
late final userDisplay = firstName.combineLatest3(
lastName,
avatarUrl,
(first, last, avatar) => UserDisplayData('$first $last', avatar),
);
}class UserDisplayWidget extends WatchingWidget {
const UserDisplayWidget({super.key});
@override
Widget build(BuildContext context) {
// Watch the combined result - one subscription, one rebuild trigger
final display = watchValue((UserProfileManager m) => m.userDisplay);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(display.avatarUrl),
),
const SizedBox(height: 16),
Text(
display.fullName,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
);
}
}Beneficio clave: Los tres valores (firstName, lastName, avatarUrl) pueden cambiar independientemente, pero el widget solo se reconstruye cuando el objeto computado UserDisplayData cambia.
Patrón: Usar mergeWith para Fuentes de Eventos
Cuando tienes múltiples fuentes de eventos del mismo tipo que deberían disparar la misma acción, usa mergeWith:
class DocumentManager {
// Three different save triggers
final saveButtonPressed = ValueNotifier<bool>(false);
final autoSaveTrigger = ValueNotifier<bool>(false);
final keyboardShortcut = ValueNotifier<bool>(false);
// Merge all save triggers into one - fires when ANY trigger fires
late final saveRequested = saveButtonPressed.mergeWith([
autoSaveTrigger,
keyboardShortcut,
]);
void save() {
print('Saving document...');
}
}class DocumentEditorWidget extends WatchingWidget {
const DocumentEditorWidget({super.key});
@override
Widget build(BuildContext context) {
// Use registerHandler to react to ANY save trigger
registerHandler(
select: (DocumentManager m) => m.saveRequested,
handler: (context, shouldSave, cancel) {
if (shouldSave) {
di<DocumentManager>().save();
}
},
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
di<DocumentManager>().saveButtonPressed.value = true;
},
child: const Text('Save'),
),
const SizedBox(height: 8),
Text(
'Auto-save, button, or Ctrl+S all trigger the same save handler',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
);
}
}Diferencia con combineLatest:
combineLatest: Combina tipos diferentes en un nuevo valor computadomergeWith: Fusiona fuentes del mismo tipo en un solo stream de eventos
Comparación: Cuándo Usar Cada Enfoque
Veamos ambos enfoques lado a lado con la misma clase Manager:
class Manager {
final value1 = ValueNotifier<int>(0);
final value2 = ValueNotifier<int>(0);
// Combined result in data layer
late final bothPositive = value1.combineLatest(
value2,
(v1, v2) => v1 > 0 && v2 > 0,
);
}class SeparateWatchesWidget extends WatchingWidget {
const SeparateWatchesWidget({super.key});
@override
Widget build(BuildContext context) {
// Two separate watches - rebuilds when EITHER changes
final value1 = watchValue((Manager m) => m.value1);
final value2 = watchValue((Manager m) => m.value2);
// Combine in UI logic
final bothPositive = value1 > 0 && value2 > 0;
print('SeparateWatchesWidget rebuilt'); // Rebuilds on every change
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text('Approach 1: Separate Watches'),
Text('Value1: $value1, Value2: $value2'),
Text(
bothPositive ? 'Both positive!' : 'At least one negative',
style: TextStyle(color: bothPositive ? Colors.green : Colors.red),
),
],
),
),
);
}
}class CombinedWatchWidget extends WatchingWidget {
const CombinedWatchWidget({super.key});
@override
Widget build(BuildContext context) {
// One watch on combined result - rebuilds only when result changes
final bothPositive = watchValue((Manager m) => m.bothPositive);
print('CombinedWatchWidget rebuilt'); // Only rebuilds when result changes
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Approach 2: Combined Watch'),
Text(
bothPositive ? 'Both positive!' : 'At least one negative',
style: TextStyle(color: bothPositive ? Colors.green : Colors.red),
),
],
),
),
);
}
}Pruébalo: Cuando incrementas value1 de -1 a 0:
SeparateWatchesWidgetse reconstruye (el valor cambió)CombinedWatchWidgetno se reconstruye (ambos todavía no son positivos)
Tabla de Decisión
| Escenario | Usar Watches Separados | Usar Combinación |
|---|---|---|
| Valores no relacionados (nombre, email, avatar) | ✅ Más simple | ❌️ Innecesario |
| Resultado computado (firstName + lastName) | ❌️ Reconstruye innecesariamente | ✅ Mejor |
| Validación de formularios (¿todos los campos válidos?) | ❌️ Reconstruye en cada tecla | ✅ Mucho mejor |
| Valores independientes todos necesarios en UI | ✅ Natural | ❌️ Más complejo |
| Sensible al rendimiento con cambios frecuentes | ❌️ Más reconstrucciones | ✅ Menos reconstrucciones |
watchIt() vs Múltiples watchValue()
La elección entre watchIt() en un ChangeNotifier y múltiples llamadas watchValue() depende de tus patrones de actualización.
Enfoque 1: watchIt() - Observar ChangeNotifier Completo
class WatchItApproach extends WatchingWidget {
const WatchItApproach({super.key});
@override
Widget build(BuildContext context) {
// Watch the entire ChangeNotifier - rebuilds on ANY property change
final settings = watchIt<UserSettings>();
print('WatchItApproach rebuilt');
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('watchIt() - Whole Object',
style: TextStyle(fontWeight: FontWeight.bold)),
Text('Dark mode: ${settings.darkMode}'),
Text('Notifications: ${settings.notifications}'),
Text('Language: ${settings.language}'),
],
),
),
);
}
}Cuándo usar:
- ✅ Necesitas **la mayoría/todas** las propiedades en tu UI
- ✅ Las propiedades se **actualizan juntas** (actualizaciones por lotes)
- ✅ Diseño simple - una llamada notifyListeners() actualiza todo
Compromiso: El widget se reconstruye incluso si solo una propiedad cambia.
Enfoque 2: Múltiples ValueNotifiers
class BetterDesignManager {
// Better: Use ValueNotifiers for individual properties
final darkMode = ValueNotifier<bool>(false);
final notifications = ValueNotifier<bool>(true);
final language = ValueNotifier<String>('en');
}
class BetterDesignWidget extends WatchingWidget {
const BetterDesignWidget({super.key});
@override
Widget build(BuildContext context) {
// Watch individual properties - only rebuilds when specific values change
final darkMode = watchValue((BetterDesignManager m) => m.darkMode);
final notifications =
watchValue((BetterDesignManager m) => m.notifications);
final language = watchValue((BetterDesignManager m) => m.language);
print('BetterDesignWidget rebuilt');
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Better: Individual ValueNotifiers',
style: TextStyle(fontWeight: FontWeight.bold)),
Text('Dark mode: $darkMode'),
Text('Notifications: $notifications'),
Text('Language: $language'),
],
),
),
);
}
}Cuándo usar:
- ✅ Las propiedades se actualizan **independientemente** y **frecuentemente**
- ✅ Solo muestras un **subconjunto** de propiedades en cada widget
- ✅ Quieres control granular sobre las reconstrucciones
Compromiso: Si múltiples propiedades se actualizan juntas, obtienes múltiples reconstrucciones. En tales casos:
- Mejor: Usa ChangeNotifier en su lugar y llama
notifyListeners()una vez después de todas las actualizaciones - Alternativa: Usa
watchPropertyValue()para reconstruir solo cuando el VALOR específico de la propiedad cambia, no en cada llamada notifyListeners
Enfoque 3: watchPropertyValue() - Actualizaciones Selectivas
Si necesitas observar un ChangeNotifier pero solo te importan cambios específicos de valor de propiedad:
class SettingsWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Solo se reconstruye cuando el VALOR de darkMode cambia
// (no en cada llamada notifyListeners)
final darkMode = watchPropertyValue((UserSettings s) => s.darkMode);
return Switch(
value: darkMode,
onChanged: (value) => di<UserSettings>().setDarkMode(value),
);
}
}Cuándo usar:
- ✅ ChangeNotifier tiene muchas propiedades
- ✅ Solo necesitas una o pocas propiedades
- ✅ Otras propiedades cambian frecuentemente pero no te importan
Beneficio clave: Se reconstruye solo cuando el valor de s.darkMode cambia, ignorando notificaciones sobre cambios de otras propiedades.
Seguridad: Caché Automático en Funciones Selectoras
Seguro Usar Operators en Selectores
Puedes usar de forma segura operators de listen_it como combineLatest() dentro de funciones selectoras de watchValue(), watchStream(), watchFuture(), y otras funciones watch. El valor predeterminado allowObservableChange: false asegura que la cadena de operator se crea una vez y se cachea.
class SafeInlineCombineWidget extends WatchingWidget {
const SafeInlineCombineWidget({super.key});
@override
Widget build(BuildContext context) {
// ✅ SAFE: Operator chain created ONCE and cached automatically
// Default allowObservableChange: false ensures selector runs only once
final sum = watchValue(
(Manager m) => m.value1.combineLatest(
m.value2,
(v1, v2) => v1 + v2,
),
);
print('Widget rebuilt with sum: $sum');
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Sum: $sum', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 16),
const Text(
'The combineLatest chain is created once and cached.\n'
'No memory leaks, no repeated chain creation!',
style: TextStyle(fontSize: 12, color: Colors.green),
textAlign: TextAlign.center,
),
],
);
}
}Cómo funciona (predeterminado allowObservableChange: false):
- Primera construcción: El selector se ejecuta, crea la cadena
combineLatest() - El resultado se cachea automáticamente
- Construcciones subsiguientes: Se reutiliza la cadena cacheada
- Se lanza excepción si la identidad del observable cambia
- Sin memory leaks, sin creación repetida de cadenas
Cuándo establecer allowObservableChange: true: Solo cuando el observable genuinamente necesita cambiar entre construcciones:
class DynamicStreamWidget extends WatchingWidget {
const DynamicStreamWidget({super.key});
@override
Widget build(BuildContext context) {
// Get dynamic value that determines which stream to watch
final useStream1 = watchValue((StreamManager m) => m.useStream1);
// ✅ CORRECT use of allowStreamChange: true
// Stream identity changes when useStream1 changes
final data = watchStream(
(StreamManager m) => useStream1 ? m.stream1 : m.stream2,
initialValue: 0,
allowStreamChange: true, // Needed because stream identity changes
);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Using stream ${useStream1 ? "1" : "2"}'),
Text('Data: ${data.data}'),
const SizedBox(height: 16),
const Text(
'allowStreamChange: true is CORRECT here\n'
'because we genuinely switch between different streams',
style: TextStyle(fontSize: 12, color: Colors.blue),
textAlign: TextAlign.center,
),
],
);
}
}Importante: Establecer allowObservableChange: true innecesariamente causa que el selector se ejecute en cada construcción, creando nuevas cadenas de operators cada vez - ¡un memory leak!
Consideraciones de Rendimiento
Frecuencia de Reconstrucción
Watches separados:
final value1 = watchValue((M m) => m.value1); // Reconstruye en cambio de value1
final value2 = watchValue((M m) => m.value2); // Reconstruye en cambio de value2
final sum = value1 + value2; // Computado en build- Reconstrucciones: 2 (una por cada cambio de valor)
- ¡Incluso si
sumno cambia!
Watch combinado:
final sum = watchValue(
(M m) => m.value1.combineLatest(m.value2, (v1, v2) => v1 + v2),
);- Reconstrucciones: Solo cuando
sumrealmente cambia - Menos reconstrucciones = mejor rendimiento
Cuándo Combinar Realmente Ayuda
Combinar proporciona beneficios reales cuando:
- Los valores cambian frecuentemente pero el resultado cambia raramente
- Computación compleja desde múltiples fuentes
- Validación - muchos campos, resultado binario (válido/inválido)
Combinar proporciona beneficio mínimo cuando:
- Todos los valores siempre se necesitan en la UI
- Los valores raramente cambian
- Las actualizaciones de UI son baratas
Errores Comunes
❌️ Crear Operators Fuera del Selector
class AntipatternCreateOutsideSelector extends WatchingWidget {
const AntipatternCreateOutsideSelector({super.key});
@override
Widget build(BuildContext context) {
final manager = di<Manager>();
// ❌ WRONG: Creating operator chain OUTSIDE selector
// This creates a NEW chain on every build - memory leak!
final combined = manager.value1.combineLatest(
manager.value2,
(v1, v2) => v1 + v2,
);
// Watching the chain that was just created
final sum = watch(combined).value;
return Text('Sum: $sum (MEMORY LEAK!)');
}
}Problema: ¡Crea nueva cadena en cada construcción - memory leak!
Solución: Crea dentro del selector:
class CorrectCreateInSelector extends WatchingWidget {
const CorrectCreateInSelector({super.key});
@override
Widget build(BuildContext context) {
// ✅ CORRECT: Create chain INSIDE selector
// Selector runs once, chain is cached automatically
final sum = watchValue(
(Manager m) => m.value1.combineLatest(
m.value2,
(v1, v2) => v1 + v2,
),
);
return Text('Sum: $sum');
}
}❌️ Usar allowObservableChange Innecesariamente
class AntipatternUnnecessaryAllowChange extends WatchingWidget {
const AntipatternUnnecessaryAllowChange({super.key});
@override
Widget build(BuildContext context) {
// ❌ WRONG: Setting allowObservableChange: true without reason
// This causes the selector to run on EVERY build
// Creates new chain every time - memory leak!
final sum = watchValue(
(Manager m) => m.value1.combineLatest(
m.value2,
(v1, v2) => v1 + v2,
),
allowObservableChange: true, // DON'T DO THIS!
);
return Text('Sum: $sum (MEMORY LEAK!)');
}
}Problema: El selector se ejecuta en cada construcción, creando nuevas cadenas.
Solución: Elimina allowObservableChange: true a menos que realmente se necesite.
❌️ Usar Getter para Valores Combinados
class WrongDataLayerApproach {
final value1 = ValueNotifier<int>(0);
final value2 = ValueNotifier<int>(0);
// ❌ WRONG if you create this as a getter
// Getter creates NEW chain every time it's accessed!
ValueListenable<int> get combined => value1.combineLatest(
value2,
(v1, v2) => v1 + v2,
);
}
class CorrectDataLayerApproach {
final value1 = ValueNotifier<int>(0);
final value2 = ValueNotifier<int>(0);
// ✅ CORRECT: Create once with late final
// Chain is created once and reused
late final combined = value1.combineLatest(
value2,
(v1, v2) => v1 + v2,
);
}Problema: El getter crea nueva cadena en cada acceso.
Solución: Usa late final para crear una vez.
Puntos Clave
✅ Watches separados son simples y funcionan bien para valores no relacionados todos necesarios en UI
✅ Combinar en la capa de datos reduce reconstrucciones cuando se computa desde múltiples fuentes
✅ Usa combineLatest() para valores dependientes con resultados computados
✅ Usa mergeWith() para múltiples fuentes de eventos del mismo tipo
✅ Seguro usar operators en selectores - caché automático con predeterminado allowObservableChange: false
✅ Nunca establezcas allowObservableChange: true a menos que el observable genuinamente cambie
✅ Crea observables combinados con late final en managers, no getters
Siguiente: Aprende sobre observar streams y futures.
Ver También
- More Watch Functions - Detalles de funciones watch individuales
- listen_it Operators - Guía completa de operators de combinación
- combineLatest Documentation - Uso detallado de combineLatest
- Best Practices - Patrones de optimización de rendimiento