El autor está disponible para consultoría y trabajo por contratoAgenda una llamada →
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 repeated chain creation, no re-subscribing!',
          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. Cuando el widget se dispone, watch_it se desuscribe y la cadena se desconecta de sus fuentes
  6. 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 una nueva cadena de operators y resuscribiéndose cada vez - trabajo innecesario en cada reconstrucción.

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 and watch() has to
    // unsubscribe from the old one and subscribe to the new one
    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 (new chain every build!)');
  }
}

Problema: Crea una nueva cadena en cada construcción - watch() tiene que cambiar de suscripción cada vez.

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 a new chain and re-subscribes every time
    final sum = watchValue(
      (Manager m) => m.value1.combineLatest(
        m.value2,
        (v1, v2) => v1 + v2,
      ),
      allowObservableChange: true, // DON'T DO THIS!
    );

    return Text('Sum: $sum (new chain every build!)');
  }
}

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. Creado por Thomas Burkhart.