Skip to content

Watching Multiple Values

When your widget needs data from multiple ValueListenables, you have several strategies to choose from. Each approach has different trade-offs in terms of code clarity, rebuild frequency, and performance.

The Two Main Approaches

Approach 1: Separate Watch Calls

Watch each value separately - widget rebuilds when ANY value changes:

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

When to use:

  • ✅ Values are unrelated
  • ✅ Simple UI logic
  • ✅ All values are needed for rendering

Rebuild behavior: Widget rebuilds whenever any of the three values changes.

Approach 2: Combining in Data Layer

Combine multiple values using listen_it operators in your manager - widget rebuilds only when the combined result changes:

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

When to use:

  • ✅ Values are related/dependent
  • ✅ Need computed result
  • ✅ Want to reduce rebuilds
  • ✅ Complex validation logic

Rebuild behavior: Widget rebuilds only when isValid changes, not when individual email or password values change (unless it affects validity).

Pattern: Form Validation with combineLatest

One of the most common use cases for combining values is form validation:

The Problem: You want to enable a submit button only when ALL form fields are valid.

Without combining: Widget rebuilds on every keystroke in any field, even if validation state doesn't change.

With combining: Widget rebuilds only when the overall validation state changes (invalid → valid or vice versa).

See the form example above for the complete pattern.

Pattern: Combining 3+ Values

For more than 2 values, use combineLatest3, combineLatest4, up to 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),
        ),
      ],
    );
  }
}

Key benefit: All three values (firstName, lastName, avatarUrl) can change independently, but the widget only rebuilds when the computed UserDisplayData object changes.

Pattern: Using mergeWith for Event Sources

When you have multiple event sources of the same type that should trigger the same action, use 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,
        ),
      ],
    );
  }
}

Difference from combineLatest:

  • combineLatest: Combines different types into a new computed value
  • mergeWith: Merges same type sources into one stream of events

Comparison: When to Use Each Approach

Let's see both approaches side by side with the same Manager class:

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

Test it: When you increment value1 from -1 to 0:

  • SeparateWatchesWidget rebuilds (value changed)
  • CombinedWatchWidget doesn't rebuild (both still not positive)

Decision Table

ScenarioUse Separate WatchesUse Combining
Unrelated values (name, email, avatar)✅ Simpler❌️ Overkill
Computed result (firstName + lastName)❌️ Rebuilds unnecessarily✅ Better
Form validation (all fields valid?)❌️ Rebuilds on every keystroke✅ Much better
Independent values all needed in UI✅ Natural❌️ More complex
Performance-sensitive with frequent changes❌️ More rebuilds✅ Fewer rebuilds

watchIt() vs Multiple watchValue()

The choice between watchIt() on a ChangeNotifier and multiple watchValue() calls depends on your update patterns.

Approach 1: watchIt() - Watch Entire ChangeNotifier

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

When to use:

  • ✅ You need most/all properties in your UI
  • ✅ Properties are updated together (batched updates)
  • ✅ Simple design - one notifyListeners() call updates everything

Trade-off: Widget rebuilds even if only one property changes.

Approach 2: Multiple 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'),
          ],
        ),
      ),
    );
  }
}

When to use:

  • ✅ Properties update independently and frequently
  • ✅ You only display a subset of properties in each widget
  • ✅ Want granular control over rebuilds

Trade-off: If multiple properties update together, you get multiple rebuilds. In such cases:

  • Better: Use ChangeNotifier instead and call notifyListeners() once after all updates
  • Alternative: Use watchPropertyValue() to rebuild only when the specific property VALUE changes, not on every notifyListeners call

Approach 3: watchPropertyValue() - Selective Updates

If you need to watch a ChangeNotifier but only care about specific property value changes:

dart
class SettingsWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // Only rebuilds when darkMode VALUE changes
    // (not on every notifyListeners call)
    final darkMode = watchPropertyValue((UserSettings s) => s.darkMode);

    return Switch(
      value: darkMode,
      onChanged: (value) => di<UserSettings>().setDarkMode(value),
    );
  }
}

When to use:

  • ✅ ChangeNotifier has many properties
  • ✅ You only need one or few properties
  • ✅ Other properties change frequently but you don't care

Key benefit: Rebuilds only when s.darkMode value changes, ignoring notifications about other property changes.

Safety: Automatic Caching in Selector Functions

Safe to Use Operators in Selectors

You can safely use listen_it operators like combineLatest() inside selector functions of watchValue(), watchStream(), watchFuture(), and other watch functions. The default allowObservableChange: false ensures the operator chain is created once and cached.

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

How it works (default allowObservableChange: false):

  1. First build: Selector runs, creates the combineLatest() chain
  2. Result is cached automatically
  3. Subsequent builds: Cached chain is reused
  4. Exception thrown if observable identity changes
  5. No memory leaks, no repeated chain creation

When to set allowObservableChange: true: Only when the observable genuinely needs to change between builds:

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

Important: Setting allowObservableChange: true unnecessarily causes the selector to run on every build, creating new operator chains each time - a memory leak!

Performance Considerations

Rebuild Frequency

Separate watches:

dart
final value1 = watchValue((M m) => m.value1);  // Rebuild on value1 change
final value2 = watchValue((M m) => m.value2);  // Rebuild on value2 change
final sum = value1 + value2;                    // Computed in build
  • Rebuilds: 2 (one for each value change)
  • Even if sum doesn't change!

Combined watch:

dart
final sum = watchValue(
  (M m) => m.value1.combineLatest(m.value2, (v1, v2) => v1 + v2),
);
  • Rebuilds: Only when sum actually changes
  • Fewer rebuilds = better performance

When Combining Actually Helps

Combining provides real benefits when:

  1. Values change frequently but result changes rarely
  2. Complex computation from multiple sources
  3. Validation - many fields, binary result (valid/invalid)

Combining provides minimal benefit when:

  1. All values are always needed in UI
  2. Values rarely change
  3. UI updates are cheap

Common Mistakes

❌️ Creating Operators Outside 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!)');
  }
}

Problem: Creates new chain on every build - memory leak!

Solution: Create inside 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');
  }
}

❌️ Using allowObservableChange Unnecessarily

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!)');
  }
}

Problem: Selector runs on every build, creating new chains.

Solution: Remove allowObservableChange: true unless actually needed.

❌️ Using Getter for Combined Values

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

Problem: Getter creates new chain every access.

Solution: Use late final to create once.

Key Takeaways

Separate watches are simple and fine for unrelated values all needed in UI

Combining in data layer reduces rebuilds when computing from multiple sources

✅ Use combineLatest() for dependent values with computed results

✅ Use mergeWith() for multiple event sources of same type

Safe to use operators in selectors - automatic caching with default allowObservableChange: false

Never set allowObservableChange: true unless observable genuinely changes

Create combined observables with late final in managers, not getters

Next: Learn about watching streams and futures.

See Also

Released under the MIT License.