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:
// ❌ 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:
// ✅ 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):
// 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):
// 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:
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:
createOncecreates the notifier once and auto-disposes itwatchsubscribes 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:
// ❌ 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:
// ✅ 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:
// ❌ 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:
// ✅ 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:
// ❌ 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:
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
],
);
}
}class UserHeader extends WatchingWidget {
@override
Widget build(BuildContext context) {
final user = watchValue((UserManager m) => m.currentUser);
return Text(user?.name ?? 'Not logged in');
}
}class TodoListHeader extends WatchingWidget {
@override
Widget build(BuildContext context) {
final todos = watchValue((TodoManager m) => m.todos);
return Text('${todos.length} todos');
}
}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:
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:
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 ofreset() - Widget accesses mocks via
get_itautomatically - 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
// 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:
// ❌ 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:
// ✅ 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
// ❌ 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'),
);
}
}// ✅ 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
- Watch Ordering Rules - CRITICAL constraints
- Debugging & Troubleshooting - Common issues
- Observing Commands - command_it integration
- Testing - Testing with
get_it