watch_it

repository·main·Indexed 41 days ago

https://github.com/flutter-it/watch_it

A reactive state management library for Flutter built on top of get_it. It allows developers to watch values, streams, and futures directly within widgets using functions like watchValue, watchStream, and watchFuture, eliminating the need for boilerplate builders such as ValueListenableBuilder or StreamBuilder.

Tokens
23.2K
Snippets
59
Records
67
Agent score
52%

What's inside watch_it

  1. Implement Pragmatic Flutter Architecture (PFA)

    main

    Pragmatic Flutter Architecture (PFA) organizes an application into three distinct components to ensure separation of concerns:

    1. Services: These wrap a single external boundary (e.g., a REST API, database, or OS service). They are responsible for converting data between external formats (like JSON) and domain models. Crucially, Services must not change app state.
    2. Managers: These encapsulate semantically related business logic (e.g., UserManager, BookingManager). They are not 1:1 ViewModels; instead, they provide Commands and ValueListenables for the UI to consume. Managers can depend on Services or other Managers.
    3. Views: These are full pages or high-level widgets. They are 'self-responsible', meaning they know which data they need. Views read data from Managers via ValueListenables and trigger modifications through Managers, never by calling Services directly.
  2. Use WatchingStatefulWidget for local state and reactivity

    main

    Extend WatchingStatefulWidget when you need to combine watch_it's reactive state management with local widget state (via setState) or standard Flutter lifecycle methods (like initState for AnimationControllers).

    class MyPage extends WatchingStatefulWidget {
      const MyPage({super.key, required this.itemId});
      final String itemId;
    
      @override
      State<MyPage> createState() => _MyPageState();
    }
    
    class _MyPageState extends State<MyPage> {
      @override
      Widget build(BuildContext context) {
        // Use watchValue for reactive state
        final data = watchValue((DataManager m) => m.data);
    
        // Use setState for local state
        onOptionChanged(String? option) {
          setState(() { /* ... */ });
        }
    
        return content;
      }
    }
  3. Critical Rules for Watching Data

    main

    Because watch_it uses index-based retrieval (similar to React Hooks), you must follow these rules to prevent breaking the mapping between calls and stored data:

    1. Call inside build(): All watch* calls must be made within the build() method.
    2. Maintain Order: Calls must be made in the same order every time the widget builds.
    3. No Conditionals: Do not wrap watch* calls in if statements or other conditional logic.
  4. Use the Proxy Pattern for Reactive Entities

    main

    Proxies wrap Data Transfer Objects (DTOs) to add reactive behavior, computed properties, and commands. This keeps the DTO as a pure data container while the Proxy handles the 'smart' logic.

    Key usage patterns:

    • Reactive Updates: Use watch(proxy) in a WatchingWidget to rebuild when the proxy calls notifyListeners().
    • Computed Properties: Add getters to the proxy that derive values from the underlying DTO.
    • Commands: Attach Command objects to the proxy to perform operations on the entity (e.g., toggleFollowCommand).
    • Smart Fallbacks: Use a proxy to hold both initial lightweight data and full data loaded later, using null-coalescing getters to fall back to the initial data if the full data isn't ready.
    class UserProxy extends ChangeNotifier {
      UserProxy(this._user);
    
      UserDto _user;
      UserDto get user => _user;
    
      set user(UserDto value) {
        _user = value;
        notifyListeners();
      }
    
      String get displayName => '${_user.firstName} ${_user.lastName}';
      bool get isVerified => _user.verificationStatus == 'verified';
    
      late final toggleFollowCommand = Command.createAsyncNoParamNoResult(
        () async {
          await di<ApiClient>().toggleFollow(_user.id);
        },
      );
    }
    
    // Use in widget
    class UserCard extends WatchingWidget {
      final UserProxy user;
      @override
      Widget build(BuildContext context) {
        watch(user); // Rebuild when proxy notifies
        final isFollowing = watch(user.toggleFollowCommand.isRunning).value;
        return Column(children: [
          Text(user.displayName),
          if (user.isVerified) Icon(Icons.verified),
        ]);
      }
    }
  5. Deduplicate Entities with DataRepository and Reference Counting

    main

    When the same entity (e.g., a User or Post) appears in multiple parts of the UI (a feed, a search result, and a detail page), use a DataRepository to ensure they all share the same DataProxy instance. This prevents inconsistent UI states.

    The Reference Counting Workflow:

    1. Creation: When a feature needs an entity, it calls repository.createProxy(item). If the proxy doesn't exist, it's created and its _referenceCount becomes 1.
    2. Sharing: If another feature requests the same entity, the repository returns the existing proxy and increments the _referenceCount.
    3. Release: When a feature is finished with the entity (e.g., a page is popped), it calls repository.releaseProxy(proxy). This decrements the _referenceCount.
    4. Disposal: When the _referenceCount reaches 0, the repository calls proxy.dispose() and removes it from its internal map.
    abstract class DataRepository<T, TProxy extends DataProxy<T>, TId> {
      final _proxies = <TId, TProxy>{};
    
      TId identify(T item);
      TProxy makeProxy(T entry);
    
      TProxy createProxy(T item) {
        final id = identify(item);
        if (!_proxies.containsKey(id)) {
          _proxies[id] = makeProxy(item);
        } else {
          _proxies[id]!.target = item; // Update with fresh data
        }
        _proxies[id]!._referenceCount++;
        return _proxies[id]!;
      }
    
      void releaseProxy(TProxy proxy) {
        proxy._referenceCount--;
        if (proxy._referenceCount == 0) {
          proxy.dispose();
          _proxies.remove(identify(proxy.target));
        }
      }
    }
  6. Manage Manager Initialization and Commands

    main

    When designing Managers, distinguish between data loading and UI interaction:

    1. init() for initial data: Use the init() method to load initial data via direct API calls. Do not route init() through a Command.
    2. Commands for UI interaction: Use Command objects as the reactive interface for the UI. Widgets should watch a command's isRunning, errors, and results properties.
    3. Avoid nesting commands: If a command needs to refresh data after a mutation (e.g., after a delete), call the API directly inside the command body. Do not call command.run() from within another command.
    class MyManager {
      final items = ValueNotifier<List<Item>>([]);
    
      // Command for UI-triggered refresh
      late final loadCommand = Command.createAsyncNoParam<List<Item>>(
        () async {
          final result = await di<ApiClient>().getItems();
          items.value = result;
          return result;
        },
        initialValue: [],
      );
    
      // init() calls API directly — no command needed
      Future<MyManager> init() async {
        items.value = await di<ApiClient>().getItems();
        return this;
      }
    }
    
    // ✅ Correct way to reload data inside a command
    late final deleteCommand = Command.createAsync<int, bool>((id) async {
      final result = await di<ApiClient>().delete(id);
      items.value = await di<ApiClient>().getItems(); // reload directly
      return result;
    }, initialValue: false);
  7. Optimize Widget Granularity with WatchingWidgets

    main

    To prevent unnecessary rebuilds, follow these rules for widget splitting:

    1. Don't over-split: A widget watching multiple objects is fine. Only split into smaller WatchingWidgets when the watched values change at different frequencies or if the rebuild is computationally expensive.
    2. Use plain widgets for parents: If a widget does not watch any values, it should be a plain StatelessWidget or StatefulWidget.
    3. Watch only what is needed: Each child WatchingWidget should call watchValue only for the specific properties it needs to render. This ensures that a change in one property only rebuilds the specific child that depends on it, rather than the entire subtree.
    // ✅ Parent doesn't watch - plain StatelessWidget
    class MyScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Column(children: [_Header(), _Counter()]);
      }
    }
    
    // Each child watches only what IT needs
    class _Header extends WatchingWidget {
      @override
      Widget build(BuildContext context) {
        final user = watchValue((Auth x) => x.currentUser);
        return Text(user.name);
      }
    }
    
    class _Counter extends WatchingWidget {
      @override
      Widget build(BuildContext context) {
        final count = watchValue((Counter x) => x.count);
        return Text('$count');
      }
    }
    // Result: user change only rebuilds _Header, count change only rebuilds _Counter
  8. Manage service scopes and shadowing

    main

    Scopes allow you to group related services and manage their lifecycle (e.g., for a user session or a shopping cart).

    Scope Shadowing: Scopes act as a stack. If you register a type in a new scope that already exists in a lower scope, the new registration shadows (hides) the original. getIt<T>() searches top-down, returning the first match. Popping the scope restores access to the original shadowed service.

    Key Scope Operations:

    • pushNewScope(): Synchronous initialization of a new scope.
    • pushNewScopeAsync(): Asynchronous initialization of a new scope.
    • popScope(): Asynchronous; removes the top scope and calls its dispose callbacks.
    • popScopesTill(name, {inclusive: false}): Removes multiple scopes until a specific named scope is reached.
    • dropScope(name): Removes a specific scope by name.
    • hasScope(name): Checks if a scope exists.
    // Push scope (synchronous init)
    getIt.pushNewScope(
      scopeName: 'user-session',
      init: (getIt) {
        getIt.registerSingleton<UserData>(currentUser);
        getIt.registerLazySingleton<UserPrefs>(() => UserPrefs(currentUser.id));
      },
    );
    
    // Push scope (async init)
    await getIt.pushNewScopeAsync(
      scopeName: 'user-session',
      init: (getIt) async {
        final prefs = await UserPrefs.load(currentUser.id);
        getIt.registerSingleton<UserPrefs>(prefs);
      },
    );
    
    // Pop scope (always async - calls dispose callbacks)
    await getIt.popScope();
    
    // Pop multiple scopes
    await getIt.popScopesTill('base-scope', inclusive: false);
    
    // Drop specific scope by name
    getIt.dropScope('user-session');
    
    // Query scopes
    getIt.hasScope('user-session');    // bool
    getIt.currentScopeName;            // String?
  9. Critical Rules for using get_it

    main

    When working with get_it, adhere to these essential rules to avoid runtime errors:

    • Registration Timing: Register all services BEFORE calling runApp().
    • Scope Initialization: pushNewScope() is synchronous. If your scope initialization requires asynchronous logic, you must use pushNewScopeAsync().
    • Scope Popping: popScope() is an asynchronous operation and returns a Future<void>. Always await it.
    • Async Readiness: allReady() returns a Future<void>. You must await it or use a FutureBuilder/watch_it to ensure async singletons are fully initialized before access.
    • Accessing Async Singletons: Once allReady() has completed, you can access initialized async singletons using the standard getIt<T>() syntax; getAsync is not required after initialization.
    • watch_it Integration: If you are using watch_it, a global di alias for GetIt.I is provided. Use di<T>() instead of getIt<T>() for consistency.
  10. Use allReady() for Async Service Loading in the UI

    main

    The allReady() method should be used within the UI layer (WatchingWidget) to handle the loading state of asynchronous singletons.

    Do not use await di.allReady() in imperative code or service initialization. Instead, let the root widget react to the readiness of the dependency injection container. This allows the UI to show a loading screen until all required services (including those in newly pushed scopes) are fully initialized.

    // ✅ UI handles loading state
    class MyApp extends WatchingWidget {
      @override
      Widget build(BuildContext context) {
        if (!allReady()) return LoadingScreen();
        return MainApp();
      }
    }
    
    // ✅ Push scope, let UI react
    Future<void> onAuthenticated(Client client) async {
      di.pushNewScope(scopeName: 'auth', init: (scope) {
        scope.registerSingleton<Client>(client);
        scope.registerSingletonAsync<MyManager>(() => MyManager().init(), dependsOn: [Client]);
      });
      // No await di.allReady() here — UI handles it
    }
  11. Manage proxy lifecycle in feeds

    main

    When using a proxy pattern for entity lifecycle management (e.g., PostProxy), you must carefully manage reference counts to prevent memory leaks or premature disposal.

    Best Practices for Refreshing Data

    When replacing an entire dataset (e.g., during a refresh), follow this pattern to ensure exit animations complete before proxies are released:

    1. Capture the current list of items (oldItems).
    2. Clear the existing items list.
    3. Populate items with new proxies from your manager (which increments their reference count).
    4. Delay the release of oldItems using a Future.delayed to allow the UI to finish animating the removal of old widgets.

    Manual Reference Management

    If you manually manipulate the feed items:

    • When adding an item at the start: Call item.incrementReferenceCount() before adding it to the list.
    • When removing an item: Call your manager's releaseProxy(item) method after removing it from the list.
    class PostsFeedSource extends PagedFeedDataSource<PostProxy> {
      // ...
      @override
      Future<void> updateFeedData() async {
        final response = await api.getPosts(type: feedType);
        if (response == null) return;
    
        // 1. Capture old items
        final oldItems = List<PostProxy>.from(items);
        
        // 2. Clear and replace
        items.clear();
        final proxies = di<PostsManager>().createProxies(response.data);
        items.addAll(proxies);
        extractNextPageParams(response.links?.next);
    
        // 3. Delay release for animations
        Future.delayed(const Duration(milliseconds: 1000), () {
          di<PostsManager>().releaseProxies(oldItems);
        });
      }
    
      @override
      void addItemAtStart(PostProxy item) {
        item.incrementReferenceCount();
        super.addItemAtStart(item);
      }
    
      @override
      void removeObject(PostProxy item) {
        super.removeObject(item);
        di<PostsManager>().releaseProxy(item);
      }
    }
  12. Use Commands instead of Async Methods

    main

    Avoid using raw Future<void> methods for asynchronous operations. Instead, use the Command-First Pattern by creating Command objects (e.g., Command.createAsync).

    Benefits:

    • Commands provide built-in loading states (isRunning).
    • Commands provide built-in error streams (errors).
    • Commands allow for easy side-effect handling via registerHandler.
    // Good: Using a Command
    late final loadDataCommand = Command.createAsyncNoParam(
      () async {
        return await api.fetchData();
      },
    );
    
    // In widget
    callOnce((_) => di<Manager>().loadDataCommand.run());
    
    // Bad: Using raw async methods
    Future<void> loadData() async { 
      await api.fetchData();
    }