Skip to content

Best Practices

Production-ready patterns, performance tips, and testing strategies for watch_it applications.

Architecture Patterns

Self-Contained Widgets

Widgets should access their dependencies directly from get_it, not via constructor parameters.

❌️ Bad - Passing managers as parameters:

dart
// ❌ Bad - Passing managers as parameters
class PassingManagersBad extends WatchingWidget {
  PassingManagersBad({required this.manager}); // DON'T DO THIS
  final TodoManager manager;

  @override
  Widget build(BuildContext context) {
    final todos = watch(manager.todos).value;
    return ListView(children: todos.map((t) => Text(t.title)).toList());
  }
}

✅ Good - Access directly:

dart
// ✅ Good - Access directly
class AccessDirectlyGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final todos = watchValue((TodoManager m) => m.todos);
    return ListView(children: todos.map((t) => Text(t.title)).toList());
  }
}

Why? Self-contained widgets are:

  • Easier to test (mock get_it, not constructor params)
  • Easier to refactor
  • Don't expose internal dependencies
  • Can access multiple services without param explosion

Separate UI State from Business State

Local UI state (form input, expansion, selection):

dart
// Local UI state
class ExpandableCard extends WatchingStatefulWidget {
  State createState() => _ExpandableCardState();
}

class _ExpandableCardState extends State<ExpandableCard> {
  bool _expanded = false; // Local UI state

  @override
  Widget build(BuildContext context) {
    // Business state from watch_it
    final data = watchValue((DataManager m) => m.data);

    return ExpansionTile(
      initiallyExpanded: _expanded,
      onExpansionChanged: (expanded) => setState(() => _expanded = expanded),
      title: Text(data),
      children: [Text('Details')],
    );
  }
}

Business state (data from API, shared state):

dart
// In manager - registered in get_it
class DataManagerExample {
  final data = ValueNotifier<List<Item>>([]);

  void fetchData() async {
    // Simulated API call
    await Future.delayed(Duration(milliseconds: 100));
    data.value = [Item('Example 1'), Item('Example 2')];
  }
}

Local Reactive State with createOnce

For widget-local reactive state that doesn't need get_it registration, combine createOnce with watch:

dart
class CounterWidget extends WatchingWidget {
  const CounterWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Create a local notifier that persists across rebuilds
    final counter = createOnce(() => ValueNotifier<int>(0));

    // Watch it directly (not from get_it)
    final count = watch(counter).value;

    return Column(
      children: [
        Text('Count: $count'),
        ElevatedButton(
          onPressed: () => counter.value++,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

When to use this pattern:

  • Widget needs its own local reactive state
  • State should persist across rebuilds (not recreated)
  • State should be automatically disposed with widget
  • Don't want to register in get_it (truly local)

Key benefits:

  • createOnce creates the notifier once and auto-disposes it
  • watch subscribes to changes and triggers rebuilds
  • No manual lifecycle management needed

Performance Optimization

Watch Only What You Need

Watch specific properties, not entire objects. The approach depends on your manager's structure:

For managers with ValueListenable properties - use watchValue():

❌️ Bad - Watching whole manager:

dart
// ❌ Bad - Watching too much
class WatchingTooMuchBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final manager = watchIt<CounterModel>(); // Rebuilds on ANY change
    final count = manager.count;
    return Container();
  }
}

✅ Good - Watch specific ValueListenable property:

dart
// ✅ Good - Watch specific property
class WatchSpecificGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final todos = watchValue(
        (TodoManager m) => m.todos); // Only rebuilds when todos change
    return Container();
  }
}

For ChangeNotifier managers - use watchPropertyValue() to rebuild only when a specific property value changes:

❌️ Bad - Rebuilds on every notifyListeners call:

dart
// ❌ Bad - Rebuilds on every settings change
class RebuildsOnEverySettingsBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final settings = watchIt<SettingsModel>();
    final darkMode =
        settings.darkMode; // Rebuilds even when other settings change
    return Container();
  }
}

✅ Good - Rebuilds only when darkMode value changes:

dart
// ✅ Good - Rebuilds only when darkMode changes
class RebuildsOnlyDarkModeGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final darkMode = watchPropertyValue((SettingsModel s) => s.darkMode);
    return Container();
  }
}

Split Large Widgets

Don't watch everything in one giant widget. Split into smaller widgets that watch only what they need. This ensures that only the smaller widgets rebuild when their data changes.

❌️ Bad - One widget watches everything:

dart
// ❌ Bad - One widget watches everything
class OneWidgetWatchesEverythingBad extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final user = watchValue((UserManager m) => m.currentUser);
    final todos = watchValue((TodoManager m) => m.todos);
    final settings = watchPropertyValue((SettingsModel m) => m.darkMode);

    return Column(
      children: [
        // When ANYTHING changes, ENTIRE dashboard rebuilds
        Text(user?.name ?? ''),
        Text('${todos.length} todos'),
        Text('Dark mode: $settings'),
      ],
    );
  }
}

✅ Good - Each widget watches its own data:

dart
class SplitWidgetsGoodDashboard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        UserHeader(), // Only rebuilds when user changes
        TodoListHeader(), // Only rebuilds when todos change
        SettingsPanelHeader(), // Only rebuilds when settings change
      ],
    );
  }
}
dart
class UserHeader extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final user = watchValue((UserManager m) => m.currentUser);
    return Text(user?.name ?? 'Not logged in');
  }
}
dart
class TodoListHeader extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final todos = watchValue((TodoManager m) => m.todos);
    return Text('${todos.length} todos');
  }
}
dart
class SettingsPanelHeader extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    final darkMode = watchPropertyValue((SettingsModel m) => m.darkMode);
    return Text('Dark mode: $darkMode');
  }
}

Const Constructors

Use const with your watching widgets

Const constructors work with all watch_it widget types: WatchingWidget, WatchingStatefulWidget, and widgets using WatchItMixin. Flutter can optimize const widgets for better rebuild performance.

Testing

Test Business Logic Separately

Keep your business logic (managers, services) separate from widgets and test them independently:

Unit test the manager:

dart
test('TodoManager filters completed todos', () {
  final manager = TodoManager();
  manager.addTodo('Task 1');
  manager.addTodo('Task 2');
  manager.todos[0].complete();

  expect(manager.completedTodos.length, 1);
  expect(manager.activeTodos.length, 1);
});

No Flutter dependencies = fast tests.

Test Widgets with Mocked Dependencies

For widget tests, use scopes to isolate dependencies. Critical: You must register any object that your widget watches BEFORE calling pumpWidget:

dart
testWidgets('TodoListWidget displays todos', (tester) async {
  // Use a scope for test isolation
  await GetIt.I.pushNewScope();

  // Register mocks BEFORE pumpWidget
  final mockManager = MockTodoManager();
  when(mockManager.todos).thenReturn([
    Todo('Task 1'),
    Todo('Task 2'),
  ]);
  GetIt.I.registerSingleton<TodoManager>(mockManager);

  // Now create the widget
  await tester.pumpWidget(MaterialApp(home: TodoListWidget()));

  expect(find.text('Task 1'), findsOneWidget);
  expect(find.text('Task 2'), findsOneWidget);

  // Clean up scope
  await GetIt.I.popScope();
});

Key insights:

  • Register watched objects BEFORE pumpWidget - the widget will try to access them during first build
  • Use pushNewScope() for test isolation instead of reset()
  • Widget accesses mocks via get_it automatically
  • Self-contained widgets are easier to test - no constructor parameters needed

For comprehensive testing strategies with get_it, see the Testing Guide.

Code Organization

Widget Structure

dart
// Widget structure
class TodoListStructure extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    // 1. One-time initialization
    callOnce((_) {
      di<TodoManagerStructure>().fetchCommand.run();
    });

    // 2. Register handlers
    registerHandler(
      select: (TodoManagerStructure m) => m.createCommand,
      handler: _onTodoCreated,
    );

    // 3. Watch reactive state
    final todos = watchValue((TodoManagerStructure m) => m.todos);
    final isLoading = watchValue((TodoManagerStructure m) => m.isLoading);

    // 4. Build UI
    if (isLoading) return CircularProgressIndicator();
    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (context, index) => Text(todos[index].title),
    );
  }

  void _onTodoCreated(
      BuildContext context, Todo? value, void Function() cancel) {
    if (value != null) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Todo created!')),
      );
    }
  }
}

Anti-Patterns

❌️ Don't Access get_it in Constructors

❌️ Bad - Accessing in constructor:

dart
// ❌ BAD - Accessing get_it in constructor
class DontAccessGetItConstructorsBad extends WatchingWidget {
  DontAccessGetItConstructorsBad() {
    // DON'T DO THIS - constructor runs before widget is attached to tree
    di<Manager>().loadMoreCommand.run(); // Will fail or cause issues
  }

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

✅ Good - Use callOnce:

dart
// ✅ GOOD - Use callOnce
class DontAccessGetItConstructorsGood extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    callOnce((_) {
      di<Manager>().loadMoreCommand.run(); // Do this instead
    });
    return Container();
  }
}

Why? Constructors run before the widget is attached to the tree, and they will be called again every time the widget gets recreated. Use callOnce() to ensure initialization happens only once when the widget is actually built.

❌️ Don't Violate Watch Ordering Rules

Watch Ordering is Critical

All watch*, callOnce, createOnce, and registerHandler calls must be in the same order on every build. This is a fundamental constraint of watch_it's design.

See Watch Ordering Rules for complete details and safe exceptions.

❌️ Don't Await Commands

dart
// ❌ Don't await commands - BAD
class DontAwaitExecuteBadAnti extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () async {
        await di<TodoManager>().createTodoCommand.runAsync(
            CreateTodoParams(title: 'New', description: '')); // Blocks UI!
      },
      child: Text('Submit'),
    );
  }
}
dart
// ✅ Don't await commands - GOOD
class DontAwaitExecuteGoodAnti extends WatchingWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => di<TodoManager>().createTodoCommand.run(CreateTodoParams(
          title: 'New', description: '')), // Returns immediately
      child: Text('Submit'),
    );
  }
}

❌️ Don't Put Watch Calls in Callbacks

See Watch Ordering Rules - watch calls must be in build(), not in callbacks.

Debugging

Enable tracing with enableTracing() or WatchItSubTreeTraceControl to understand rebuild behavior. For detailed debugging techniques and troubleshooting common issues, see Debugging & Troubleshooting.

See Also

Released under the MIT License.