Skip to content

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:

dart
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:

dart
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;
    },
  );
}
dart
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:

dart
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),
  );
}
dart
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:

dart
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...');
  }
}
dart
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 computado
  • mergeWith: 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:

dart
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,
  );
}
dart
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),
            ),
          ],
        ),
      ),
    );
  }
}
dart
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:

  • SeparateWatchesWidget se reconstruye (el valor cambió)
  • CombinedWatchWidget no se reconstruye (ambos todavía no son positivos)

Tabla de Decisión

EscenarioUsar Watches SeparadosUsar 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

dart
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

dart
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:

dart
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.

dart
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):

  1. Primera construcción: El selector se ejecuta, crea la cadena combineLatest()
  2. El resultado se cachea automáticamente
  3. Construcciones subsiguientes: Se reutiliza la cadena cacheada
  4. Se lanza excepción si la identidad del observable cambia
  5. Sin memory leaks, sin creación repetida de cadenas

Cuándo establecer allowObservableChange: true: Solo cuando el observable genuinamente necesita cambiar entre construcciones:

dart
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:

dart
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 sum no cambia!

Watch combinado:

dart
final sum = watchValue(
  (M m) => m.value1.combineLatest(m.value2, (v1, v2) => v1 + v2),
);
  • Reconstrucciones: Solo cuando sum realmente cambia
  • Menos reconstrucciones = mejor rendimiento

Cuándo Combinar Realmente Ayuda

Combinar proporciona beneficios reales cuando:

  1. Los valores cambian frecuentemente pero el resultado cambia raramente
  2. Computación compleja desde múltiples fuentes
  3. Validación - muchos campos, resultado binario (válido/inválido)

Combinar proporciona beneficio mínimo cuando:

  1. Todos los valores siempre se necesitan en la UI
  2. Los valores raramente cambian
  3. Las actualizaciones de UI son baratas

Errores Comunes

❌️ Crear Operators Fuera del Selector

dart
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:

dart
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

dart
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

dart
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

Publicado bajo la Licencia MIT.