The author is available for consulting & contract workBook a call →
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 repeated chain creation, no re-subscribing!',
          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. When the widget is disposed, watch_it unsubscribes and the chain detaches from its sources
  6. 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 a new operator chain and re-subscribing each time - needless work on every rebuild.

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

Problem: Creates a new chain on every build - watch() has to switch subscriptions each time.

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

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. Built by Thomas Burkhart.