{"slug": "taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive", "title": "Taming Flutter Infinite Scroll (Part 2): Turning ScrollController into a Reactive State Machine with CubitSignalMixin", "summary": "A developer introduced CubitSignalMixin and BlocSignalMixin from the bloc_signals package to turn Flutter's ScrollController into a reactive state machine, overcoming Dart's single-inheritance limitation. The mixins add state management and event handling capabilities directly to ScrollController, eliminating the need for third-party pagination packages and reducing boilerplate.", "body_md": "In [Part 1: Taming Flutter Infinite Scroll: Why 3 Lines of async* Missed the Point, and How BlocSignal Fixes It](https://dev.to/gde/taming-flutter-infinite-scroll-why-3-lines-of-async-missed-the-point-and-how-blocsignal-fixes-it-3n48), we explored why wrapping mutable state in `async*`\n\ngenerators and `StreamIterator`\n\ncracks under pressure when users rapidly fling a list. We demonstrated how `BlocSignal`\n\n’s streamless `droppable()`\n\ntransformer solves thumb-flinging race conditions synchronously at the event boundary without Rx streams or microtask lag.\n\nYet, even after solving event concurrency with a pure BLoC, many Flutter developers are left with a nagging architectural itch.\n\nSearch pub.dev for `\"infinite scroll\"`\n\nor `\"pagination\"`\n\n, and you will find dozens of packages—`infinite_scroll_pagination`\n\n, `lazy_load_scrollview`\n\n, `flutter_pagewise`\n\n, `loadmore`\n\n. It is practically a rite of passage for every Flutter developer to install at least one of them.\n\nWhy do these packages exist in such numbers?\n\nBecause implementing pagination with standard Flutter controllers requires tedious widget-level plumbing:\n\n`StatefulWidget`\n\n.`ScrollController`\n\n.`_scrollController.addListener(_onScroll)`\n\nin `initState`\n\n.`removeListener`\n\nand `_scrollController.dispose()`\n\nin `dispose()`\n\n.`offset >= maxScrollExtent * 0.9`\n\n).`context.read<PostsBloc>().add(...)`\n\n).Unfortunately, the third-party pagination packages on pub.dev often extract a heavy architectural tax:\n\n`PagedListView`\n\n, fighting your slivers, custom scroll physics, and layout styling.What if you did not need a third-party pagination package at all? What if Flutter's standard `ScrollController`\n\ncould **itself** be your reactive state container?\n\nLet us examine why that was historically impossible in Dart—and how composable mixins change everything.\n\nWhy couldn't Flutter's `ScrollController`\n\njust extend `BlocSignal`\n\nor `CubitSignal`\n\n?\n\nIn Dart, a class can extend only **one** superclass.\n\nFlutter's `ScrollController`\n\nextends `ChangeNotifier`\n\n(which implements `Listenable`\n\n). If you want a class to also be a `CubitSignal`\n\nor `BlocSignal`\n\n, Dart's single-inheritance constraint stops you dead in your tracks:\n\n```\n// ❌ Impossible in Dart (Multiple inheritance is forbidden):\nclass PaginatedPostsController extends ScrollController, BlocSignal<PostsEvent, PostsState> {\n  // Dart analyzer error: Each class can have only one superclass.\n}\n```\n\nHistorically, this constraint forced developers into two unsatisfying compromises:\n\n`_bloc`\n\nreference, requiring tedious method forwarding and lifecycle delegation.`ScrollController`\n\nand a `PostsBloc`\n\nas separate objects in the widget tree, gluing them together with `initState`\n\nlisteners and cleaning both up in `dispose()`\n\n.With ** CubitSignalMixin** and\n\n`BlocSignalMixin`\n\n`bloc_signals`\n\n, that single-inheritance wall is demolished.Because `BlocSignal`\n\nhas a minimal, highly disciplined API contract, mixing it into arbitrary classes introduces zero namespace collisions:\n\n```\n┌────────────────────────────────────────────────────────────────────────┐\n│                        BlocSignal Mixin Architecture                   │\n├────────────────────────────────┬───────────────────────────────────────┤\n│ Mixin                          │ Capabilities Added                    │\n├────────────────────────────────┼───────────────────────────────────────┤\n│ CubitSignalMixin<StateType>    │ state, stateValue, emit(newState),    │\n│                                │ equals(), createEffect(), close()     │\n├────────────────────────────────┼───────────────────────────────────────┤\n│ BlocSignalMixin<Event, State>  │ on<E>(), concurrency transformers     │\n│                                │ (droppable, restartable), add(event)  │\n└────────────────────────────────┴───────────────────────────────────────┘\n```\n\nWhen a class adopts `CubitSignalMixin<StateType>`\n\n, it implements `BlocSignalBase<StateType>`\n\n. It gains:\n\n`state`\n\n).`stateValue`\n\n).`emit(newState)`\n\ndrops transitions when `newState == currentState`\n\n).And when combined with `BlocSignalMixin<Event, StateType>`\n\n, it gains full event-driven execution with streamless transformers like `droppable()`\n\nand `restartable()`\n\n.\n\nThis unlocks two clean architectural patterns for infinite scroll.\n\n`PagingScrollController`\n\n(Separation of Concerns)\nIf your architectural philosophy demands that your domain business logic remain 100% pure Dart (with zero imports of `package:flutter/widgets.dart`\n\n), you can turn `ScrollController`\n\ninto a focused, reactive boolean signal:\n\n```\nimport 'package:bloc_signals/bloc_signals.dart';\nimport 'package:flutter/widgets.dart';\n\n/// A ScrollController that is also a CubitSignal emitting whether \n/// the scroll viewport is within [threshold] pixels of the bottom.\nclass PagingScrollController extends ScrollController \n    with CubitSignalMixin<bool> {\n  PagingScrollController({this.threshold = 200.0}) {\n    // 1. Initialize the CubitSignalMixin with initial state\n    initCubitSignal(initialState: false);\n\n    // 2. Listen to scroll metrics internally\n    addListener(_onScrollChanged);\n  }\n\n  /// Remaining scroll extent threshold in logical pixels (default: 200.0).\n  final double threshold;\n  bool _isControllerDisposed = false;\n\n  void _onScrollChanged() {\n    if (!hasClients) return;\n    // position.extentAfter returns the exact remaining pixels after the viewport!\n    final isNearBottom = position.extentAfter <= threshold;\n\n    // 3. emit() automatically de-duplicates:\n    // Only triggers subscribers when the boolean flips between false and true!\n    emit(isNearBottom);\n  }\n\n  @override\n  void dispose() {\n    if (_isControllerDisposed) return;\n    _isControllerDisposed = true;\n    removeListener(_onScrollChanged);\n    close();\n    super.dispose();\n  }\n\n  @override\n  Future<void> close() async {\n    if (!_isControllerDisposed) {\n      _isControllerDisposed = true;\n      removeListener(_onScrollChanged);\n      super.dispose();\n    }\n    await super.close();\n  }\n}\n```\n\n`extentAfter`\n\nSecret: Why Pixels Beat Percentages\nNotice line 20:\n\n```\nfinal isNearBottom = position.extentAfter <= threshold;\n```\n\nMost Flutter pagination tutorials write something like:\n\n```\n// ⚠️ The percentage trap:\nfinal isBottom = offset >= maxScrollExtent * 0.9;\n```\n\nCalculating a percentage (such as `0.9`\n\n) creates an erratic user experience:\n\nFlutter's `ScrollPosition.extentAfter`\n\nreturns the **exact quantity of content in logical pixels remaining after the viewport's trailing edge** (`math.max(maxScrollExtent - pixels, 0.0)`\n\n).\n\nUsing `position.extentAfter <= 200.0`\n\n:\n\n`maxScrollExtent * 0.9`\n\n, no reading `offset`\n\n, and no bounds-checking when a list is empty. It is a single, clean comparison.Notice line 27: `emit(isNearBottom);`\n\n.\n\nAs a user scrolls vigorously near the bottom, scroll notifications fire dozens of times across 91%, 93%, 97%, and 99% of the viewport. In naive Flutter code, this requires manual boolean guards to prevent triggering duplicate actions.\n\nWith `CubitSignalMixin`\n\n, **de-duplication is automatic**. Because `emit()`\n\nchecks `newState == currentState`\n\n, calling `emit(true)`\n\ntwenty times in a row produces **zero** spurious signal updates. The signal fires exactly once when crossing the threshold downward, and exactly once when scrolling back upward!\n\nConnecting this to your domain `PostsBloc`\n\nrequires just a single declarative effect:\n\n```\npagingController.createEffect(() {\n  if (pagingController.stateValue) {\n    postsBloc.add(const PostsFetched());\n  }\n});\n```\n\nThe domain BLoC remains completely independent of Flutter, while the widget avoids doing manual scroll extent arithmetic.\n\nNow, let us take the architectural leap.\n\nWhat if you do not want a separate controller, a separate BLoC, and glue code between them? What if your controller **is** the `ScrollController`\n\n, and your controller **is** the `BlocSignal`\n\n?\n\nHere is `PaginatedPostsController`\n\n:\n\n```\nimport 'dart:async';\n\nimport 'package:bloc_signals/bloc_signals.dart';\nimport 'package:flutter/widgets.dart';\nimport '../models/post.dart';\n\nclass PaginatedPostsController extends ScrollController\n    with\n        CubitSignalMixin<PostsState>,\n        BlocSignalMixin<PostsEvent, PostsState> {\n  PaginatedPostsController({\n    this.threshold = 200.0,\n    required PostRepository repository,\n  }) : _repository = repository {\n    // 1. Initialize CubitSignal state\n    initCubitSignal(initialState: const PostsState());\n\n    // 2. Streamless concurrency: drop overlapping scroll triggers\n    on<PostsFetched>(\n      _onPostsFetched,\n      transformer: droppable(),\n    );\n\n    // 3. Streamless concurrency: cancel and restart on search query change\n    on<PostsSearchChanged>(\n      _onPostsSearchChanged,\n      transformer: restartable(),\n    );\n\n    // 4. Controller listens to its own scroll geometry!\n    addListener(_onScrollChanged);\n  }\n\n  /// Remaining scroll extent threshold in logical pixels (default: 200.0).\n  final double threshold;\n  final PostRepository _repository;\n  bool _isControllerDisposed = false;\n\n  void _onScrollChanged() {\n    if (!hasClients) return;\n    // Single, clean extentAfter check:\n    if (position.extentAfter <= threshold) {\n      add(const PostsFetched());\n    }\n  }\n\n  Future<void> _onPostsFetched(\n    PostsFetched event,\n    void Function(PostsState) emit,\n  ) async {\n    if (stateValue.hasReachedMax) return;\n\n    try {\n      if (stateValue.status == PostsStatus.initial) {\n        final posts = await _repository.fetchPosts(\n          startIndex: 0,\n          count: 10,\n          query: stateValue.searchQuery,\n        );\n        return emit(stateValue.copyWith(\n          status: PostsStatus.success,\n          posts: posts,\n          hasReachedMax: false,\n        ));\n      }\n\n      final posts = await _repository.fetchPosts(\n        startIndex: stateValue.posts.length,\n        count: 10,\n        query: stateValue.searchQuery,\n      );\n\n      emit(posts.isEmpty\n          ? stateValue.copyWith(hasReachedMax: true)\n          : stateValue.copyWith(\n              status: PostsStatus.success,\n              posts: [...stateValue.posts, ...posts],\n              hasReachedMax: stateValue.posts.length + posts.length >= 30,\n            ));\n    } catch (_) {\n      emit(stateValue.copyWith(status: PostsStatus.failure));\n    }\n  }\n\n  Future<void> _onPostsSearchChanged(\n    PostsSearchChanged event,\n    void Function(PostsState) emit,\n  ) async {\n    final posts = await _repository.fetchPosts(\n      startIndex: 0,\n      count: 10,\n      query: event.query,\n    );\n    emit(stateValue.copyWith(\n      status: PostsStatus.success,\n      posts: posts,\n      hasReachedMax: false,\n      searchQuery: event.query,\n    ));\n  }\n\n  @override\n  void dispose() {\n    if (_isControllerDisposed) return;\n    _isControllerDisposed = true;\n    removeListener(_onScrollChanged);\n    close();\n    super.dispose();\n  }\n\n  @override\n  Future<void> close() async {\n    if (!_isControllerDisposed) {\n      _isControllerDisposed = true;\n      removeListener(_onScrollChanged);\n      super.dispose();\n    }\n    await super.close();\n  }\n}\n```\n\nLook at what this class accomplishes:\n\n`ScrollController`\n\n`ListView.builder(controller: controller)`\n\n.`BlocSignalBase`\n\n`BlocSignalBuilder`\n\nor provide it with `BlocSignalProvider`\n\n.`add(const PostsFetched())`\n\n.`transformer: droppable()`\n\nguarantees that rapid thumb flings while a network request is in-flight are synchronously ignored on the same frame.`StatelessWidget`\n\nNow observe what happens to the Flutter UI layer:\n\n```\nimport 'package:bloc_signals_flutter/bloc_signals_flutter.dart';\nimport 'package:flutter/material.dart';\nimport '../controllers/paginated_posts_controller.dart';\n\nclass SelfPagingPostsView extends StatelessWidget {\n  const SelfPagingPostsView({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    final controller = context.read<PaginatedPostsController>();\n\n    return Scaffold(\n      appBar: AppBar(\n        title: const Text('Self-Paging Controller (Stateless)'),\n        bottom: PreferredSize(\n          preferredSize: const Size.fromHeight(60),\n          child: Padding(\n            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),\n            child: TextField(\n              decoration: const InputDecoration(\n                hintText: 'Search posts...',\n                prefixIcon: Icon(Icons.search),\n                border: OutlineInputBorder(),\n              ),\n              onChanged: (query) {\n                controller.add(PostsSearchChanged(query));\n              },\n            ),\n          ),\n        ),\n      ),\n      body: BlocSignalBuilder<PaginatedPostsController, PostsState>(\n        builder: (context, state) {\n          switch (state.status) {\n            case PostsStatus.initial:\n              return const Center(child: CircularProgressIndicator());\n\n            case PostsStatus.failure:\n              return const Center(child: Text('Failed to load posts'));\n\n            case PostsStatus.success:\n              if (state.posts.isEmpty) {\n                return const Center(child: Text('No posts found.'));\n              }\n              return ListView.builder(\n                controller: controller, // Plugs directly into Flutter's native ListView!\n                itemCount: state.hasReachedMax\n                    ? state.posts.length\n                    : state.posts.length + 1,\n                itemBuilder: (context, index) {\n                  if (index >= state.posts.length) {\n                    return const Padding(\n                      padding: EdgeInsets.all(16.0),\n                      child: Center(child: CircularProgressIndicator()),\n                    );\n                  }\n                  final post = state.posts[index];\n                  return ListTile(\n                    leading: CircleAvatar(child: Text('${post.id}')),\n                    title: Text(post.title),\n                    subtitle: Text(post.body),\n                  );\n                },\n              );\n          }\n        },\n      ),\n    );\n  }\n}\n```\n\nNotice what is **completely absent** from this widget:\n\n`StatefulWidget`\n\n`State.initState`\n\n`State.dispose`\n\n`ScrollController`\n\nlistener wiringThe widget is a pure, declarative, 100% `StatelessWidget`\n\n. It renders the state when signals emit, and it routes user interactions directly to the controller.\n\nOne important detail when unifying a Flutter `ChangeNotifier`\n\nand a `BlocSignalBase`\n\nis lifecycle coordination.\n\nWhen providing `PaginatedPostsController`\n\nthrough `BlocSignalProvider`\n\n:\n\n```\nBlocSignalProvider<PaginatedPostsController>(\n  lazy: false,\n  create: (context) => PaginatedPostsController(repository: repository)\n    ..add(const PostsFetched()),\n  child: const MaterialApp(home: SelfPagingPostsView()),\n)\n```\n\n`BlocSignalProvider`\n\nautomatically invokes `bloc.close()`\n\nwhen the provider is unmounted.\n\nFurthermore, passing `controller: controller`\n\nto `ListView.builder`\n\ndoes **not** cause the `ListView`\n\nto dispose the controller; in Flutter, widgets only dispose controllers that they instantiated internally.\n\nTo ensure safe, leak-free teardown regardless of how the controller is managed, we implement an idempotent teardown guard:\n\n```\nbool _isControllerDisposed = false;\n\n@override\nvoid dispose() {\n  if (_isControllerDisposed) return;\n  _isControllerDisposed = true;\n  removeListener(_onScrollChanged);\n  close();\n  super.dispose();\n}\n\n@override\nFuture<void> close() async {\n  if (!_isControllerDisposed) {\n    _isControllerDisposed = true;\n    removeListener(_onScrollChanged);\n    super.dispose();\n  }\n  await super.close();\n}\n```\n\nWhether teardown is triggered via Flutter's `dispose()`\n\nor `BlocSignalProvider`\n\n's `close()`\n\n, listeners are removed, signals are cleaned up, and neither `super.dispose()`\n\nnor `super.close()`\n\nis ever executed more than once.\n\n| Dimension | Third-Party Packages (for example `infinite_scroll_pagination` ) |\nClassic Straight BLoC (`examples/infinite_scroll` ) |\nPattern A: Reactive `PagingScrollController`\n|\nPattern B: Self-Paging Mixin (`examples/infinite_scroll_mixin` ) |\n|---|---|---|---|---|\nWidget Tree Impact |\n❌ Proprietary wrappers (`PagedListView` ) |\n✅ 100% Standard Flutter widgets | ✅ 100% Standard Flutter widgets | ✅ 100% Standard Flutter widgets |\nUI Widget Structure |\n`StatefulWidget` or wrapper |\n`StatefulWidget` (`initState` /`dispose` ) |\n`StatefulWidget` or effect |\n✅ 100%\n`StatelessWidget` |\nSource of Truth |\n❌ Competing controllers fighting BLoC | ✅ Single BLoC container | ✅ Single BLoC container | ✅ Single unified controller |\nConcurrency Guard |\nBrittle UI-level guards | ✅ Synchronous `droppable()`\n|\n✅ Synchronous `droppable()`\n|\n✅ Synchronous `droppable()`\n|\nDomain Layer Separation |\nCoupled to package | ✅ 100% Pure Dart domain | ✅ 100% Pure Dart domain | Blended UI controller & state machine |\nExternal Dependencies |\nHeavy third-party package | None (pure `bloc_signals` ) |\nNone (pure `bloc_signals` ) |\nNone (pure `bloc_signals` ) |\n\nBoth patterns are first-class citizens in the `BlocSignal`\n\nrepository:\n\n**Use the Classic Straight BLoC Strategy (** when:\n\n`examples/infinite_scroll`\n\n)`StatefulWidget`\n\ncontroller lifecycles in the UI layer.**Use the Self-Paging Mixin Strategy (** when:\n\n`examples/infinite_scroll_mixin`\n\n)`StatelessWidget`\n\nwith zero glue code.Infinite scroll does not have to be a rite of passage filled with race condition bugs, competing controllers, or proprietary widget wrappers.\n\nBy combining `droppable()`\n\nconcurrency with `CubitSignalMixin`\n\nand `BlocSignalMixin`\n\n:\n\n`StatelessWidget`\n\nUI screens without a single line of `initState`\n\nor `dispose`\n\nboilerplate.`bloc_signals`\n\non pub.dev`bloc_signals_flutter`\n\non pub.dev", "url": "https://wpnews.pro/news/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive", "canonical_source": "https://dev.to/gde/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive-state-machine-cgh", "published_at": "2026-09-03 17:31:52+00:00", "updated_at": "2026-09-03 17:56:14.141574+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Flutter", "Dart", "ScrollController", "CubitSignalMixin", "BlocSignalMixin", "bloc_signals", "BlocSignal"], "alternates": {"html": "https://wpnews.pro/news/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive", "markdown": "https://wpnews.pro/news/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive.md", "text": "https://wpnews.pro/news/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive.txt", "jsonld": "https://wpnews.pro/news/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive.jsonld"}}