Skip to content

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:

dart
// 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:

dart
// ❌ 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 show is true: watches [showDetails, todos]
  • Order changes = error!

❌️ Watch Inside Loops

dart
// ❌ 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

dart
// ❌ 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:

dart
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:

dart
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:

dart
// ✓ 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:

  1. Call all watch functions at the top of build()
  2. THEN use conditional logic with the values
  3. Order stays consistent

Safe Pattern Examples

dart
// ✓ 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);
  }
}
dart
// ✓ 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 if statement
  • 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:

  1. Move conditional watches to the END of your build method, OR
  2. 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 if statements 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():

  1. watch_it increments the index
  2. Checks if subscription at that index exists
  3. If yes, reuses it
  4. 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

Released under the MIT License.