Skip to content

Debugging & Troubleshooting

Common errors, solutions, debugging techniques, and troubleshooting strategies for watch_it.

Common Errors

"Watch ordering violation detected!"

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.

Cause: Watch calls inside if statements followed by other watches, causing order to change between builds.

Solution: See Watch Ordering Rules for detailed explanation, examples, and safe patterns.

Debugging tip: Call enableTracing() in your build method to see exact source locations of conflicting watch statements.

"watch() called outside build"

Error message:

watch() can only be called inside build()

Cause: Trying to use watch functions in callbacks, constructors, or other methods.

Example:

dart
// BAD
class WatchOutsideBuildBad extends WatchingWidget {
  WatchOutsideBuildBad() {
    final data = watchValue((Manager m) => m.data); // Wrong context!
  }

  void onPressed() {
    final data = watchValue((Manager m) => m.data); // Wrong!
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

Solution: Only call watch functions directly in build():

dart
// GOOD
class WatchOutsideBuildGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final data = watchValue((Manager m) => m.data); // Correct!

    return ElevatedButton(
      onPressed: () {
        doSomething(data); // Use the value
      },
      child: Text('$data'),
    );
  }
}

void doSomething(String data) {}

"Type 'X' is not a subtype of type 'Listenable'"

Error message:

type 'MyManager' is not a subtype of type 'Listenable'

Cause: Using watchIt<T>() on an object that isn't a Listenable.

Example:

dart
// BAD
class TodoManagerNotListenable {
  // Not a Listenable!
  final todos = ValueNotifier<List<Todo>>([]);
}

class NotListenableBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // ignore: type_argument_not_matching_bounds
    final manager = watchIt<TodoManagerNotListenable>(); // ERROR!
    return Container();
  }
}

Solution: Use watchValue() instead:

dart
// GOOD
class NotListenableGoodWatchValue extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final todos = watchValue((TodoManagerNotListenable m) => m.todos);
    return Container();
  }
}

Or make your manager extend ChangeNotifier:

dart
// Also GOOD
class TodoManagerListenable extends ChangeNotifier {
  List<Todo> _todos = [];

  void addTodo(Todo todo) {
    _todos.add(todo);
    notifyListeners(); // Now it's a Listenable
  }
}

class NotListenableGoodChangeNotifier extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final manager = watchIt<TodoManagerListenable>(); // Works now!
    return Container();
  }
}

"get_it: Object/factory with type X is not registered"

Error message:

get_it: Object/factory with type TodoManager is not registered inside GetIt

Cause: Trying to watch an object that hasn't been registered in get_it.

Solution: Register it before using:

dart
void main() {
  // Register BEFORE runApp
  di.registerSingleton<TodoManager>(TodoManager(DataService(ApiClient())));

  runApp(MyApp());
}

See get_it Object Registration for all registration methods.

Widget doesn't rebuild when data changes

Symptoms:

  • Data changes but UI doesn't update
  • print() shows new values but widget still shows old data

Common causes:

1. Not watching the data

dart
class NotWatchingBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // BAD - Not watching, just accessing
    final manager = di<TodoManager>();
    final todos = manager.todos.value; // No watch!
    return Container();
  }
}
dart
class NotWatchingGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // GOOD - Actually watching
    final todos = watchValue((TodoManager m) => m.todos);
    return Container();
  }
}

2. Not notifying changes

dart
// BAD - Changing value without notifying
class TodoManagerNotNotifying {
  final todos = ValueNotifier<List<Todo>>([]);

  void addTodo(Todo todo) {
    todos.value.add(todo); // Modifies list but doesn't notify!
  }
}

Option 1 - Use ListNotifier from listen_it (recommended):

dart
// GOOD - ListNotifier automatically notifies on mutations
class TodoManagerListNotifier {
  final todos = ListNotifier<Todo>(data: []);

  void addTodo(Todo todo) {
    todos.add(todo); // Automatically notifies listeners!
  }
}

Option 2 - Use custom ValueNotifier with manual notification:

dart
// GOOD - Extend ValueNotifier and call notifyListeners
class TodoManagerCustomNotifier extends ValueNotifier<List<Todo>> {
  TodoManagerCustomNotifier() : super([]);

  void addTodo(Todo todo) {
    value.add(todo);
    notifyListeners(); // Manually trigger notification
  }
}

See listen_it Collections for ListNotifier, MapNotifier, and SetNotifier.

Memory leaks - subscriptions not cleaned up

Symptoms:

  • Memory usage grows over time
  • Old widgets still reacting to changes
  • Performance degrades

Cause: Not using WatchingWidget or WatchItMixin - doing manual subscriptions.

Solution: Always use watch_it widgets:

dart
// BAD - Manual subscriptions leak
class MemoryLeakBad extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final manager = di<Manager>();
    manager.data.addListener(() {
      // This leaks! No cleanup
    });
    return Container();
  }
}
dart
// GOOD - Automatic cleanup
class MemoryLeakGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final data = watchValue((Manager m) => m.data);
    return Text('$data');
  }
}

registerHandler not firing

Symptoms:

  • Handler callback never executes
  • Side effects (navigation, dialogs) don't happen
  • No errors thrown

Common causes:

1. Handler registered after conditional return

dart
// BAD - Handler registered AFTER early return
class HandlerAfterReturnBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final isLoading = watchValue((SaveManager m) => m.isLoading);

    if (isLoading) {
      return CircularProgressIndicator(); // Returns early!
    }

    // This handler never gets registered when loading!
    registerHandler(
      select: (SaveManager m) => m.saveCommand,
      handler: (context, result, cancel) {
        Navigator.pop(context);
      },
    );

    return MyForm();
  }
}

Solution: Register handlers BEFORE any conditional returns:

dart
// GOOD - Handler registered before conditional logic
class HandlerBeforeReturnGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    registerHandler(
      select: (SaveManager m) => m.saveCommand,
      handler: (context, result, cancel) {
        Navigator.pop(context);
      },
    );

    final isLoading = watchValue((SaveManager m) => m.isLoading);

    if (isLoading) {
      return CircularProgressIndicator();
    }

    return MyForm();
  }
}

2. Widget destroyed during command execution

If the widget containing the handler is destroyed and rebuilt while the command is running, the handler will be re-registered and may miss state changes.

Example: A button inside a widget that rebuilds on hover:

dart
// ❌ BAD: Handler inside widget that gets destroyed on parent rebuild
class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});

  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  bool _isHovered = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) => setState(() => _isHovered = true),
      onExit: (_) => setState(() => _isHovered = false),
      child: Container(
        color: _isHovered ? Colors.blue.shade100 : Colors.white,
        child: SaveButtonWithHandler(), // ❌ Destroyed on every hover!
      ),
    );
  }
}

class SaveButtonWithHandler extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // ❌ Handler is registered here - if parent rebuilds,
    // this widget is destroyed and handler is lost!
    registerHandler(
      select: (TodoManager m) => m.createTodoCommand.results,
      handler: (context, result, cancel) {
        if (result.hasData) {
          Navigator.of(context).pop(); // May never execute!
        }
      },
    );

    return ElevatedButton(
      onPressed: () => di<TodoManager>().createTodoCommand(
        CreateTodoParams(title: 'New', description: 'Task'),
      ),
      child: const Text('Save'),
    );
  }
}

Solution: Move the handler to a stable parent widget:

dart
// ✅ GOOD: Handler in stable parent widget
class StableParentWidget extends WatchingStatefulWidget {
  const StableParentWidget({super.key});

  @override
  State<StableParentWidget> createState() => _StableParentWidgetState();
}

class _StableParentWidgetState extends State<StableParentWidget> {
  bool _isHovered = false;

  @override
  Widget build(BuildContext context) {
    // ✅ Handler registered in parent - survives child rebuilds
    registerHandler(
      select: (TodoManager m) => m.createTodoCommand.results,
      handler: (context, result, cancel) {
        if (result.hasData) {
          Navigator.of(context).pop(); // Always executes!
        }
      },
    );

    return MouseRegion(
      onEnter: (_) => setState(() => _isHovered = true),
      onExit: (_) => setState(() => _isHovered = false),
      child: Container(
        color: _isHovered ? Colors.blue.shade100 : Colors.white,
        child: const SaveButtonOnly(), // Child can rebuild safely
      ),
    );
  }
}

class SaveButtonOnly extends StatelessWidget {
  const SaveButtonOnly({super.key});

  @override
  Widget build(BuildContext context) {
    // Just the button - no handler here
    return ElevatedButton(
      onPressed: () => di<TodoManager>().createTodoCommand(
        CreateTodoParams(title: 'New', description: 'Task'),
      ),
      child: const Text('Save'),
    );
  }
}

Debugging Techniques

Enable watch_it Tracing

Get detailed logs of watch subscriptions and source locations for ordering violations:

dart
class MyWidget extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // Call at the start of build to enable tracing
    enableTracing(
      logRebuilds: true,
      logHandlers: true,
      logHelperFunctions: true,
    );

    final todos = watchValue((TodoManager m) => m.todos);
    // ... rest of build
  }
}

Benefits:

  • Shows which watch triggered the rebuild of your widget
  • Shows exact source locations of watch calls
  • Helps identify ordering violations
  • Tracks rebuild activity
  • Shows handler executions

Use case: When your widget rebuilds unexpectedly, enable tracing to see exactly which watched value changed and triggered the rebuild. This helps you identify if you're watching too much data or the wrong properties.

Alternative: Use WatchItSubTreeTraceControl widget to enable tracing for a specific subtree:

dart
// First, enable subtree tracing globally (typically in main())
enableSubTreeTracing = true;

// Then wrap ONLY the problematic widget/screen - NOT the whole app!
// Otherwise you'll drown in logs from every widget
return Scaffold(
  body: WatchItSubTreeTraceControl(
    logRebuilds: true,        // Required: log rebuild events
    logHandlers: true,        // Required: log handler executions
    logHelperFunctions: true, // Required: log helper function calls
    child: ProblematicWidget(), // Only the widget you're debugging
  ),
);

Important: Wrap only the specific widget or screen causing issues, not your entire app. Tracing the whole app generates overwhelming amounts of logs.

Note:

  • You can nest multiple WatchItSubTreeTraceControl widgets - the nearest ancestor's settings apply
  • Must set enableSubTreeTracing = true globally for subtree controls to work

Isolate the problem

Create minimal reproduction:

dart
class IsolateProblem extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // Minimal example - just the watch and rebuild
    final value = watchValue((CounterManager m) => m.count);

    print('Rebuild with value: $value');

    return Text('Value: $value');
  }
}

This isolates:

  • Does the watch subscription work?
  • Does the widget rebuild on data change?
  • Are there ordering issues?

Getting Help

When reporting issues:

  1. Minimal reproduction - Isolate the problem
  2. Versions - watch_it, Flutter, Dart versions
  3. Error messages - Full stack trace
  4. Expected vs actual - What should happen vs what happens
  5. Code sample - Complete, runnable example

Where to ask:

See Also

Released under the MIT License.