# Taming Flutter Infinite Scroll (Part 2): Turning ScrollController into a Reactive State Machine with CubitSignalMixin

> Source: <https://dev.to/gde/taming-flutter-infinite-scroll-part-2-turning-scrollcontroller-into-a-reactive-state-machine-cgh>
> Published: 2026-09-03 17:31:52+00:00

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*`

generators and `StreamIterator`

cracks under pressure when users rapidly fling a list. We demonstrated how `BlocSignal`

’s streamless `droppable()`

transformer solves thumb-flinging race conditions synchronously at the event boundary without Rx streams or microtask lag.

Yet, even after solving event concurrency with a pure BLoC, many Flutter developers are left with a nagging architectural itch.

Search pub.dev for `"infinite scroll"`

or `"pagination"`

, and you will find dozens of packages—`infinite_scroll_pagination`

, `lazy_load_scrollview`

, `flutter_pagewise`

, `loadmore`

. It is practically a rite of passage for every Flutter developer to install at least one of them.

Why do these packages exist in such numbers?

Because implementing pagination with standard Flutter controllers requires tedious widget-level plumbing:

`StatefulWidget`

.`ScrollController`

.`_scrollController.addListener(_onScroll)`

in `initState`

.`removeListener`

and `_scrollController.dispose()`

in `dispose()`

.`offset >= maxScrollExtent * 0.9`

).`context.read<PostsBloc>().add(...)`

).Unfortunately, the third-party pagination packages on pub.dev often extract a heavy architectural tax:

`PagedListView`

, 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`

could **itself** be your reactive state container?

Let us examine why that was historically impossible in Dart—and how composable mixins change everything.

Why couldn't Flutter's `ScrollController`

just extend `BlocSignal`

or `CubitSignal`

?

In Dart, a class can extend only **one** superclass.

Flutter's `ScrollController`

extends `ChangeNotifier`

(which implements `Listenable`

). If you want a class to also be a `CubitSignal`

or `BlocSignal`

, Dart's single-inheritance constraint stops you dead in your tracks:

```
// ❌ Impossible in Dart (Multiple inheritance is forbidden):
class PaginatedPostsController extends ScrollController, BlocSignal<PostsEvent, PostsState> {
  // Dart analyzer error: Each class can have only one superclass.
}
```

Historically, this constraint forced developers into two unsatisfying compromises:

`_bloc`

reference, requiring tedious method forwarding and lifecycle delegation.`ScrollController`

and a `PostsBloc`

as separate objects in the widget tree, gluing them together with `initState`

listeners and cleaning both up in `dispose()`

.With ** CubitSignalMixin** and

`BlocSignalMixin`

`bloc_signals`

, that single-inheritance wall is demolished.Because `BlocSignal`

has a minimal, highly disciplined API contract, mixing it into arbitrary classes introduces zero namespace collisions:

```
┌────────────────────────────────────────────────────────────────────────┐
│                        BlocSignal Mixin Architecture                   │
├────────────────────────────────┬───────────────────────────────────────┤
│ Mixin                          │ Capabilities Added                    │
├────────────────────────────────┼───────────────────────────────────────┤
│ CubitSignalMixin<StateType>    │ state, stateValue, emit(newState),    │
│                                │ equals(), createEffect(), close()     │
├────────────────────────────────┼───────────────────────────────────────┤
│ BlocSignalMixin<Event, State>  │ on<E>(), concurrency transformers     │
│                                │ (droppable, restartable), add(event)  │
└────────────────────────────────┴───────────────────────────────────────┘
```

When a class adopts `CubitSignalMixin<StateType>`

, it implements `BlocSignalBase<StateType>`

. It gains:

`state`

).`stateValue`

).`emit(newState)`

drops transitions when `newState == currentState`

).And when combined with `BlocSignalMixin<Event, StateType>`

, it gains full event-driven execution with streamless transformers like `droppable()`

and `restartable()`

.

This unlocks two clean architectural patterns for infinite scroll.

`PagingScrollController`

(Separation of Concerns)
If your architectural philosophy demands that your domain business logic remain 100% pure Dart (with zero imports of `package:flutter/widgets.dart`

), you can turn `ScrollController`

into a focused, reactive boolean signal:

```
import 'package:bloc_signals/bloc_signals.dart';
import 'package:flutter/widgets.dart';

/// A ScrollController that is also a CubitSignal emitting whether 
/// the scroll viewport is within [threshold] pixels of the bottom.
class PagingScrollController extends ScrollController 
    with CubitSignalMixin<bool> {
  PagingScrollController({this.threshold = 200.0}) {
    // 1. Initialize the CubitSignalMixin with initial state
    initCubitSignal(initialState: false);

    // 2. Listen to scroll metrics internally
    addListener(_onScrollChanged);
  }

  /// Remaining scroll extent threshold in logical pixels (default: 200.0).
  final double threshold;
  bool _isControllerDisposed = false;

  void _onScrollChanged() {
    if (!hasClients) return;
    // position.extentAfter returns the exact remaining pixels after the viewport!
    final isNearBottom = position.extentAfter <= threshold;

    // 3. emit() automatically de-duplicates:
    // Only triggers subscribers when the boolean flips between false and true!
    emit(isNearBottom);
  }

  @override
  void dispose() {
    if (_isControllerDisposed) return;
    _isControllerDisposed = true;
    removeListener(_onScrollChanged);
    close();
    super.dispose();
  }

  @override
  Future<void> close() async {
    if (!_isControllerDisposed) {
      _isControllerDisposed = true;
      removeListener(_onScrollChanged);
      super.dispose();
    }
    await super.close();
  }
}
```

`extentAfter`

Secret: Why Pixels Beat Percentages
Notice line 20:

```
final isNearBottom = position.extentAfter <= threshold;
```

Most Flutter pagination tutorials write something like:

```
// ⚠️ The percentage trap:
final isBottom = offset >= maxScrollExtent * 0.9;
```

Calculating a percentage (such as `0.9`

) creates an erratic user experience:

Flutter's `ScrollPosition.extentAfter`

returns the **exact quantity of content in logical pixels remaining after the viewport's trailing edge** (`math.max(maxScrollExtent - pixels, 0.0)`

).

Using `position.extentAfter <= 200.0`

:

`maxScrollExtent * 0.9`

, no reading `offset`

, and no bounds-checking when a list is empty. It is a single, clean comparison.Notice line 27: `emit(isNearBottom);`

.

As 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.

With `CubitSignalMixin`

, **de-duplication is automatic**. Because `emit()`

checks `newState == currentState`

, calling `emit(true)`

twenty 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!

Connecting this to your domain `PostsBloc`

requires just a single declarative effect:

```
pagingController.createEffect(() {
  if (pagingController.stateValue) {
    postsBloc.add(const PostsFetched());
  }
});
```

The domain BLoC remains completely independent of Flutter, while the widget avoids doing manual scroll extent arithmetic.

Now, let us take the architectural leap.

What if you do not want a separate controller, a separate BLoC, and glue code between them? What if your controller **is** the `ScrollController`

, and your controller **is** the `BlocSignal`

?

Here is `PaginatedPostsController`

:

```
import 'dart:async';

import 'package:bloc_signals/bloc_signals.dart';
import 'package:flutter/widgets.dart';
import '../models/post.dart';

class PaginatedPostsController extends ScrollController
    with
        CubitSignalMixin<PostsState>,
        BlocSignalMixin<PostsEvent, PostsState> {
  PaginatedPostsController({
    this.threshold = 200.0,
    required PostRepository repository,
  }) : _repository = repository {
    // 1. Initialize CubitSignal state
    initCubitSignal(initialState: const PostsState());

    // 2. Streamless concurrency: drop overlapping scroll triggers
    on<PostsFetched>(
      _onPostsFetched,
      transformer: droppable(),
    );

    // 3. Streamless concurrency: cancel and restart on search query change
    on<PostsSearchChanged>(
      _onPostsSearchChanged,
      transformer: restartable(),
    );

    // 4. Controller listens to its own scroll geometry!
    addListener(_onScrollChanged);
  }

  /// Remaining scroll extent threshold in logical pixels (default: 200.0).
  final double threshold;
  final PostRepository _repository;
  bool _isControllerDisposed = false;

  void _onScrollChanged() {
    if (!hasClients) return;
    // Single, clean extentAfter check:
    if (position.extentAfter <= threshold) {
      add(const PostsFetched());
    }
  }

  Future<void> _onPostsFetched(
    PostsFetched event,
    void Function(PostsState) emit,
  ) async {
    if (stateValue.hasReachedMax) return;

    try {
      if (stateValue.status == PostsStatus.initial) {
        final posts = await _repository.fetchPosts(
          startIndex: 0,
          count: 10,
          query: stateValue.searchQuery,
        );
        return emit(stateValue.copyWith(
          status: PostsStatus.success,
          posts: posts,
          hasReachedMax: false,
        ));
      }

      final posts = await _repository.fetchPosts(
        startIndex: stateValue.posts.length,
        count: 10,
        query: stateValue.searchQuery,
      );

      emit(posts.isEmpty
          ? stateValue.copyWith(hasReachedMax: true)
          : stateValue.copyWith(
              status: PostsStatus.success,
              posts: [...stateValue.posts, ...posts],
              hasReachedMax: stateValue.posts.length + posts.length >= 30,
            ));
    } catch (_) {
      emit(stateValue.copyWith(status: PostsStatus.failure));
    }
  }

  Future<void> _onPostsSearchChanged(
    PostsSearchChanged event,
    void Function(PostsState) emit,
  ) async {
    final posts = await _repository.fetchPosts(
      startIndex: 0,
      count: 10,
      query: event.query,
    );
    emit(stateValue.copyWith(
      status: PostsStatus.success,
      posts: posts,
      hasReachedMax: false,
      searchQuery: event.query,
    ));
  }

  @override
  void dispose() {
    if (_isControllerDisposed) return;
    _isControllerDisposed = true;
    removeListener(_onScrollChanged);
    close();
    super.dispose();
  }

  @override
  Future<void> close() async {
    if (!_isControllerDisposed) {
      _isControllerDisposed = true;
      removeListener(_onScrollChanged);
      super.dispose();
    }
    await super.close();
  }
}
```

Look at what this class accomplishes:

`ScrollController`

`ListView.builder(controller: controller)`

.`BlocSignalBase`

`BlocSignalBuilder`

or provide it with `BlocSignalProvider`

.`add(const PostsFetched())`

.`transformer: droppable()`

guarantees that rapid thumb flings while a network request is in-flight are synchronously ignored on the same frame.`StatelessWidget`

Now observe what happens to the Flutter UI layer:

```
import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';
import 'package:flutter/material.dart';
import '../controllers/paginated_posts_controller.dart';

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

  @override
  Widget build(BuildContext context) {
    final controller = context.read<PaginatedPostsController>();

    return Scaffold(
      appBar: AppBar(
        title: const Text('Self-Paging Controller (Stateless)'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(60),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
            child: TextField(
              decoration: const InputDecoration(
                hintText: 'Search posts...',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
              onChanged: (query) {
                controller.add(PostsSearchChanged(query));
              },
            ),
          ),
        ),
      ),
      body: BlocSignalBuilder<PaginatedPostsController, PostsState>(
        builder: (context, state) {
          switch (state.status) {
            case PostsStatus.initial:
              return const Center(child: CircularProgressIndicator());

            case PostsStatus.failure:
              return const Center(child: Text('Failed to load posts'));

            case PostsStatus.success:
              if (state.posts.isEmpty) {
                return const Center(child: Text('No posts found.'));
              }
              return ListView.builder(
                controller: controller, // Plugs directly into Flutter's native ListView!
                itemCount: state.hasReachedMax
                    ? state.posts.length
                    : state.posts.length + 1,
                itemBuilder: (context, index) {
                  if (index >= state.posts.length) {
                    return const Padding(
                      padding: EdgeInsets.all(16.0),
                      child: Center(child: CircularProgressIndicator()),
                    );
                  }
                  final post = state.posts[index];
                  return ListTile(
                    leading: CircleAvatar(child: Text('${post.id}')),
                    title: Text(post.title),
                    subtitle: Text(post.body),
                  );
                },
              );
          }
        },
      ),
    );
  }
}
```

Notice what is **completely absent** from this widget:

`StatefulWidget`

`State.initState`

`State.dispose`

`ScrollController`

listener wiringThe widget is a pure, declarative, 100% `StatelessWidget`

. It renders the state when signals emit, and it routes user interactions directly to the controller.

One important detail when unifying a Flutter `ChangeNotifier`

and a `BlocSignalBase`

is lifecycle coordination.

When providing `PaginatedPostsController`

through `BlocSignalProvider`

:

```
BlocSignalProvider<PaginatedPostsController>(
  lazy: false,
  create: (context) => PaginatedPostsController(repository: repository)
    ..add(const PostsFetched()),
  child: const MaterialApp(home: SelfPagingPostsView()),
)
```

`BlocSignalProvider`

automatically invokes `bloc.close()`

when the provider is unmounted.

Furthermore, passing `controller: controller`

to `ListView.builder`

does **not** cause the `ListView`

to dispose the controller; in Flutter, widgets only dispose controllers that they instantiated internally.

To ensure safe, leak-free teardown regardless of how the controller is managed, we implement an idempotent teardown guard:

```
bool _isControllerDisposed = false;

@override
void dispose() {
  if (_isControllerDisposed) return;
  _isControllerDisposed = true;
  removeListener(_onScrollChanged);
  close();
  super.dispose();
}

@override
Future<void> close() async {
  if (!_isControllerDisposed) {
    _isControllerDisposed = true;
    removeListener(_onScrollChanged);
    super.dispose();
  }
  await super.close();
}
```

Whether teardown is triggered via Flutter's `dispose()`

or `BlocSignalProvider`

's `close()`

, listeners are removed, signals are cleaned up, and neither `super.dispose()`

nor `super.close()`

is ever executed more than once.

| Dimension | Third-Party Packages (for example `infinite_scroll_pagination` ) |
Classic Straight BLoC (`examples/infinite_scroll` ) |
Pattern A: Reactive `PagingScrollController`
|
Pattern B: Self-Paging Mixin (`examples/infinite_scroll_mixin` ) |
|---|---|---|---|---|
Widget Tree Impact |
❌ Proprietary wrappers (`PagedListView` ) |
✅ 100% Standard Flutter widgets | ✅ 100% Standard Flutter widgets | ✅ 100% Standard Flutter widgets |
UI Widget Structure |
`StatefulWidget` or wrapper |
`StatefulWidget` (`initState` /`dispose` ) |
`StatefulWidget` or effect |
✅ 100%
`StatelessWidget` |
Source of Truth |
❌ Competing controllers fighting BLoC | ✅ Single BLoC container | ✅ Single BLoC container | ✅ Single unified controller |
Concurrency Guard |
Brittle UI-level guards | ✅ Synchronous `droppable()`
|
✅ Synchronous `droppable()`
|
✅ Synchronous `droppable()`
|
Domain Layer Separation |
Coupled to package | ✅ 100% Pure Dart domain | ✅ 100% Pure Dart domain | Blended UI controller & state machine |
External Dependencies |
Heavy third-party package | None (pure `bloc_signals` ) |
None (pure `bloc_signals` ) |
None (pure `bloc_signals` ) |

Both patterns are first-class citizens in the `BlocSignal`

repository:

**Use the Classic Straight BLoC Strategy (** when:

`examples/infinite_scroll`

)`StatefulWidget`

controller lifecycles in the UI layer.**Use the Self-Paging Mixin Strategy (** when:

`examples/infinite_scroll_mixin`

)`StatelessWidget`

with 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.

By combining `droppable()`

concurrency with `CubitSignalMixin`

and `BlocSignalMixin`

:

`StatelessWidget`

UI screens without a single line of `initState`

or `dispose`

boilerplate.`bloc_signals`

on pub.dev`bloc_signals_flutter`

on pub.dev
