Watch Ordering Rules
The Golden Rule
All watch function calls must occur in the SAME ORDER on every build.
This is the most important rule in watch_it. Violating it will cause errors or unexpected behavior.
Why Does Order Matter?
watch_it uses a global state mechanism similar to React Hooks. Each watch call is assigned an index based on its position in the build sequence. When the widget rebuilds, watch_it expects to find the same watches in the same order.
What happens if order changes:
- ❌️ Runtime errors
- ❌️ Wrong data displayed
- ❌️ Unexpected rebuilds
- ❌️ Memory leaks
Correct Pattern
✅ All watch calls happen in the same order every time:
// CORRECT: All watch calls in the SAME ORDER every build
// This is the golden rule of watch_it!
class GoodWatchOrderingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// ALWAYS call watch functions in the same order
// Even if you don't use the value, the order matters!
// Order: 1. todos
final todos = watchValue((TodoManager m) => m.todos);
// Order: 2. isLoading
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
// Order: 3. counter (local state)
final counter = createOnce(() => SimpleCounter());
final count = watch(counter).value;
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
return Scaffold(
appBar: AppBar(title: const Text('✓ Good Watch Ordering')),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.green.shade100,
child: const Text(
'✓ All watch calls in same order every build',
style:
TextStyle(color: Colors.green, fontWeight: FontWeight.bold),
),
),
ListTile(
title: const Text('Todos count'),
trailing: Text('${todos.length}'),
),
ListTile(
title: const Text('Loading'),
trailing: Text(isLoading.toString()),
),
ListTile(
title: const Text('Counter'),
trailing: Text('$count'),
),
const Spacer(),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
ElevatedButton(
onPressed: () =>
di<TodoManager>().fetchTodosCommand.run(),
child: const Text('Refresh Todos'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: counter.increment,
child: const Text('Increment Counter'),
),
],
),
),
],
),
);
}
}Why this is correct:
- Line 17: Always watches
todos - Line 20: Always watches
isLoading - Line 23-24: Always creates and watches
counter - Order never changes, even when data updates
Common Violations
❌️ Conditional Watch Calls
The most common mistake is putting watch calls inside conditional statements:
// ❌ WRONG: Conditional watch calls - ORDER CHANGES between builds
// This will cause errors or unexpected behavior!
class BadWatchOrderingWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final showDetails = createOnce(() => ValueNotifier(false));
final show = watch(showDetails).value;
// ❌ WRONG: Conditional watch - order changes!
// When show changes, the order of watch calls changes
if (show) {
// This watch call only happens sometimes
final todos = watchValue((TodoManager m) => m.todos);
return Scaffold(
appBar: AppBar(title: const Text('❌ Bad Ordering - Details View')),
body: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return ListTile(title: Text(todos[index].title));
},
),
);
}
// Different watch calls in different branches
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
return Scaffold(
appBar: AppBar(title: const Text('❌ Bad Ordering - Simple View')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
color: Colors.red.shade100,
child: const Text(
'❌ BAD: Watch call order changes based on conditions!\n'
'This violates the golden rule.',
style: TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
if (isLoading) const CircularProgressIndicator(),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => showDetails.value = true,
child: const Text('Toggle View (will break)'),
),
],
),
),
);
}
}Why this breaks: -When show is false: watches [showDetails, isLoading]
- When
showis true: watches [showDetails, todos] - Order changes = error!
❌️ Watch Inside Loops
// ❌ WRONG - order changes based on list length
class WatchInsideLoopsWrong extends WatchingWidget {
@override
Widget build(BuildContext context) {
final items = <Item>[Item('1', 'Apple'), Item('2', 'Banana')];
final widgets = <Widget>[];
for (final item in items) {
// DON'T DO THIS - number of watch calls changes with list length
final data = watchValue((Manager m) => m.data);
widgets.add(Text(data));
}
return Column(children: widgets);
}
}❌️ Watch in Callbacks
// ❌ WRONG - watch in button callback
class WatchInCallbacksWrong extends WatchingWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
// DON'T DO THIS - watch calls must be in build(), not callbacks
final data = watchValue((Manager m) => m.data); // Error!
print(data);
},
child: Text('Press'),
);
}
}Safe Exceptions to the Rule
Understanding When Conditionals Are Safe
The ordering rule only matters when watches may or may not be called on the SAME execution path.
- Conditional watches at the end - safe because no watches follow
- Early returns - always safe because they create separate execution paths
✅ Conditional Watches at the END
Conditional watches are perfectly safe when they're the last watches in your build:
class MyWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
// These watches always run in the same order
final todos = watchValue((TodoManager m) => m.todos);
final isLoading = watchValue((TodoManager m) => m.isLoading);
// ✅ Conditional watch at the END - perfectly safe!
if (showDetails) {
final details = watchValue((TodoManager m) => m.selectedDetails);
return DetailView(details);
}
return ListView(/* ... */);
}
}Why this is safe:
- First two watches always execute in same order
- Conditional watch is LAST - no subsequent watches to disrupt
- On rebuild: same order maintained
✅ Early Returns Are Always Safe
Early returns don't affect watch ordering because watches after them are never called:
class MyWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final isLoading = watchValue((DataManager m) => m.isLoading);
// ✅ Early return - completely safe!
if (isLoading) {
return CircularProgressIndicator();
}
// This watch only executes when NOT loading
final data = watchValue((DataManager m) => m.data);
if (data.isEmpty) {
return Text('No data');
}
return ListView(/* ... */);
}
}Why this is safe:
- Watches after early returns simply never execute
- They don't participate in the ordering mechanism
- No order disruption possible
Key principle: The danger is watches that may or may not be called on the SAME build path FOLLOWED by other watches. Early returns create separate execution paths, so watches after them are not part of the ordering for that path.
Safe Conditional Patterns
✅ Call ALL watches first, THEN use conditions:
// ✓ SAFE: How to handle conditional logic while maintaining watch order
class SafeConditionalWatchWidget extends WatchingWidget {
@override
Widget build(BuildContext context) {
final showDetails = createOnce(() => ValueNotifier(false));
final show = watch(showDetails).value;
// ✓ CORRECT: ALWAYS call all watch functions regardless of conditions
// The ORDER stays the same every build!
final todos = watchValue((TodoManager m) => m.todos);
final isLoading =
watchValue((TodoManager m) => m.fetchTodosCommand.isRunning);
callOnce((_) {
di<TodoManager>().fetchTodosCommand.run();
});
// Now use conditional logic AFTER all watch calls
return Scaffold(
appBar: AppBar(
title: Text(show ? '✓ Details View' : '✓ Simple View'),
),
body: show
? Column(
children: [
Container(
padding: const EdgeInsets.all(16),
color: Colors.green.shade100,
child: const Text(
'✓ SAFE: All watch calls happen in same order,\n'
'only UI changes conditionally',
style: TextStyle(color: Colors.green),
textAlign: TextAlign.center,
),
),
Expanded(
child: ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(todos[index].title),
subtitle: Text(todos[index].description),
);
},
),
),
],
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.all(16),
color: Colors.green.shade100,
child: const Text(
'✓ SAFE: Same watch calls every build',
style: TextStyle(color: Colors.green),
),
),
Text('Total Todos: ${todos.length}'),
const SizedBox(height: 8),
if (isLoading) const CircularProgressIndicator(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => showDetails.value = !show,
child: Icon(show ? Icons.visibility_off : Icons.visibility),
),
);
}
}Pattern:
- Call all watch functions at the top of
build() - THEN use conditional logic with the values
- Order stays consistent
Safe Pattern Examples
// ✓ CORRECT - All watches before conditionals
class SafePatternConditional extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Watch everything first
final data = watchValue((Manager m) => m.data);
final isLoading = watchValue((Manager m) => m.isLoading);
final error = watchValue((Manager m) => m.error);
// Then use conditionals
if (error != null) {
return ErrorWidget(error);
}
if (isLoading) {
return CircularProgressIndicator();
}
return Text(data);
}
}// ✓ CORRECT - Watch list, then iterate over values
class SafePatternListIteration extends WatchingWidget {
@override
Widget build(BuildContext context) {
// Watch the list once
final items = watchValue((Manager m) => m.items);
// Iterate over values (not watch calls)
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index]; // No watch here!
return ListTile(title: Text(item.name));
},
);
}
}Troubleshooting
Error: "Watch ordering violation detected!"
Full error message:
Watch ordering violation detected!
You have conditional watch calls (inside if/switch statements) that are
causing watch_it to retrieve the wrong objects on rebuild.
Fix: Move ALL conditional watch calls to the END of your build method.
Only the LAST watch call can be conditional.What happened:
- You have a watch inside an
ifstatement - This watch is followed by other watches
- On rebuild, the condition changed, causing watch_it to try to retrieve the wrong type at that position
- A TypeError was thrown when trying to cast the watch entry
Solution:
- Move conditional watches to the END of your build method, OR
- Make all watches unconditional and use the values conditionally instead
Tip: Call enableTracing() in your build method to see exact source locations of the conflicting watch statements.
Best Practices Checklist
✅ DO:
- Call all watches at the top of
build()when possible - Use unconditional watch calls for watches that need to execute on all paths
- Store values in variables, use variables conditionally
- Watch the full list, iterate over values
- Use conditional watches at the end (after all other watches)
- Use early returns freely - they're always safe
❌️ DON'T:
- Put watches in
ifstatements when followed by other watches - Put watches in loops
- Put watches in callbacks
Advanced: Why This Happens
watch_it uses a global _watchItState variable that tracks:
- Current widget being built
- Index of current watch call
- List of previous watch subscriptions
When you call watch():
watch_itincrements the index- Checks if subscription at that index exists
- If yes, reuses it
- If no, creates new subscription
If order changes:
- Index 0 expects subscription A, gets subscription B
- Subscriptions leak or get mixed up
- Everything breaks
This is similar to React Hooks rules for the same reason.
See Also
- Getting Started - Basic
watch_itusage - Watch Functions - All watch functions
- Best Practices - General patterns
- Debugging & Troubleshooting - Common issues