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:
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:
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'),
),
],
);
}
}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:
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),
),
],
);
}
}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:
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,
),
],
);
}
}Difference from combineLatest:
combineLatest: Combines different types into a new computed valuemergeWith: 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:
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),
),
],
),
),
);
}
}Test it: When you increment value1 from -1 to 0:
SeparateWatchesWidgetrebuilds (value changed)CombinedWatchWidgetdoesn't rebuild (both still not positive)
Decision Table
| Scenario | Use Separate Watches | Use 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
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
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:
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.
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):
- First build: Selector runs, creates the
combineLatest()chain - Result is cached automatically
- Subsequent builds: Cached chain is reused
- Exception thrown if observable identity changes
- No memory leaks, no repeated chain creation
When to set allowObservableChange: true: Only when the observable genuinely needs to change between builds:
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:
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
sumdoesn't change!
Combined watch:
final sum = watchValue(
(M m) => m.value1.combineLatest(m.value2, (v1, v2) => v1 + v2),
);- Rebuilds: Only when
sumactually changes - Fewer rebuilds = better performance
When Combining Actually Helps
Combining provides real benefits when:
- Values change frequently but result changes rarely
- Complex computation from multiple sources
- Validation - many fields, binary result (valid/invalid)
Combining provides minimal benefit when:
- All values are always needed in UI
- Values rarely change
- UI updates are cheap
Common Mistakes
❌️ Creating Operators Outside 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!)');
}
}Problem: Creates new chain on every build - memory leak!
Solution: Create inside 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');
}
}❌️ Using allowObservableChange Unnecessarily
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
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
- More Watch Functions - Individual watch function details
- listen_it Operators - Complete guide to combining operators
- combineLatest Documentation - Detailed combineLatest usage
- Best Practices - Performance optimization patterns