Examples

Infinite Scroll

Build infinite scroll step by step with Shard and throttle.

Overview

This guide walks you through building an infinite list that:

  • Loads more items when the user scrolls near the bottom
  • Uses throttle so rapid scrolls don’t trigger too many API calls
  • Handles loading and error states

We’ll do it in three steps: state model → shard (with throttle) → UI with scroll listener.


Step 1: Define the state

You need a state that holds the list, pagination, and loading/error flags.

InfiniteScrollState – items, current page, whether there’s more data, loading and error state. Optionally a throttle counter so you can show “throttled” feedback in the UI.

class InfiniteScrollState {
  final List<ListItem> items;
  final bool isLoading;
  final bool hasMore;
  final int page;
  final String? error;
  final int throttleCount; // optional: show how often throttle fired

  const InfiniteScrollState({
    required this.items,
    required this.isLoading,
    required this.hasMore,
    required this.page,
    this.error,
    this.throttleCount = 0,
  });

  InfiniteScrollState copyWith({
    List<ListItem>? items,
    bool? isLoading,
    bool? hasMore,
    int? page,
    String? error,
    int? throttleCount,
  }) =>
      InfiniteScrollState(
        items: items ?? this.items,
        isLoading: isLoading ?? this.isLoading,
        hasMore: hasMore ?? this.hasMore,
        page: page ?? this.page,
        error: error ?? this.error,
        throttleCount: throttleCount ?? this.throttleCount,
      );
}

ListItem – minimal model for one row (you can replace with your own):

class ListItem {
  final String id;
  final String title;
  ListItem({required this.id, required this.title});
}

Step 2: Create the shard

Use a plain Shard<InfiniteScrollState> and call throttle inside loadMore() so that even if the UI calls loadMore() often (e.g. on every scroll tick), the actual fetch runs at most once per second.

How throttle fits in:

  • loadMore() is called from the scroll listener.
  • You wrap the real work in throttle('loadMore', () => _performLoadMore(), duration: Duration(seconds: 1)).
  • If the last execution was less than 1 second ago, throttle skips this call and returns false; otherwise it runs and returns true.
  • Optionally you can increment throttleCount when throttle skips, so the UI can show “throttled N times”.

Implement the shard:

import 'package:shard/shard.dart';
import 'infinite_scroll_state.dart'; // your state + ListItem

class InfiniteScrollShard extends Shard<InfiniteScrollState> {
  InfiniteScrollShard()
      : super(
          const InfiniteScrollState(
            items: [],
            isLoading: false,
            hasMore: true,
            page: 0,
          ),
        );

  /// Call this from the scroll listener. Throttle ensures we don't hit the API too often.
  Future<void> loadMore() async {
    final wasExecuted = throttle(
      'loadMore',
      () => _performLoadMore(),
      duration: const Duration(seconds: 1),
    );

    if (!wasExecuted) {
      emit(state.copyWith(throttleCount: state.throttleCount + 1));
    }
  }

  Future<void> _performLoadMore() async {
    if (state.isLoading || !state.hasMore) return;

    emit(state.copyWith(isLoading: true, error: null));

    try {
      await Future.delayed(const Duration(milliseconds: 500)); // simulate API
      final newItems = _fetchPage(state.page + 1);
      final newPage = state.page + 1;
      final hasMore = newPage < 10; // e.g. 10 pages max

      emit(state.copyWith(
        items: [...state.items, ...newItems],
        isLoading: false,
        hasMore: hasMore,
        page: newPage,
      ));
    } catch (e) {
      emit(state.copyWith(isLoading: false, error: e.toString()));
    }
  }

  List<ListItem> _fetchPage(int page) {
    final start = (page - 1) * 10;
    return List.generate(10, (i) {
      final n = start + i + 1;
      return ListItem(id: 'item_$n', title: 'Item $n');
    });
  }

  void reset() {
    emit(const InfiniteScrollState(
      items: [],
      isLoading: false,
      hasMore: true,
      page: 0,
      throttleCount: 0,
    ));
  }
}

Step 3: Wire up the UI

Provide the shard, then use a ScrollController and ShardBuilder<InfiniteScrollShard, InfiniteScrollState> so the list and loading/error UI react to state.

Main app – provide the shard:

home: ShardProvider<InfiniteScrollShard>(
  create: () => InfiniteScrollShard(),
  child: const InfiniteScrollScreen(),
),

Infinite scroll screen – one-time setup: create a ScrollController, add a listener that calls loadMore() when near the bottom (e.g. 80%), and load the first page in addPostFrameCallback. In build, use ShardBuilder and branch on empty+loading, empty+error, or list with a footer loading indicator.

import 'package:flutter/material.dart';
import 'package:shard/shard.dart';
import '../shard/infinite_scroll_shard.dart';
import '../models/infinite_scroll_state.dart';

class InfiniteScrollScreen extends StatefulWidget {
  const InfiniteScrollScreen({super.key});

  @override
  State<InfiniteScrollScreen> createState() => _InfiniteScrollScreenState();
}

class _InfiniteScrollScreenState extends State<InfiniteScrollScreen> {
  late final ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(_onScroll);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<InfiniteScrollShard>().loadMore();
    });
  }

  @override
  void dispose() {
    _scrollController.removeListener(_onScroll);
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    final pos = _scrollController.position;
    if (pos.pixels >= pos.maxScrollExtent * 0.8) {
      context.read<InfiniteScrollShard>().loadMore();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Infinite Scroll'),
        actions: [
          ShardSelector<InfiniteScrollShard, InfiniteScrollState, int>(
            selector: (state) => state.throttleCount,
            builder: (context, count) => Padding(
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
              child: Center(
                child: Text(
                  count > 0 ? 'Throttled: $count' : 'Ready',
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ),
            ),
          ),
          IconButton(
            icon: const Icon(Icons.refresh),
            tooltip: 'Reset',
            onPressed: () {
              context.read<InfiniteScrollShard>().reset();
              _scrollController.jumpTo(0);
              context.read<InfiniteScrollShard>().loadMore();
            },
          ),
        ],
      ),
      body: SafeArea(
        child: ShardBuilder<InfiniteScrollShard, InfiniteScrollState>(
          builder: (context, state) {
            if (state.items.isEmpty && state.isLoading) {
              return const Center(child: CircularProgressIndicator());
            }

            if (state.items.isEmpty && state.error != null) {
              return Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(Icons.error_outline, size: 64, color: Colors.red.shade300),
                    const SizedBox(height: 16),
                    Text('Error: ${state.error}', textAlign: TextAlign.center),
                    const SizedBox(height: 16),
                    ElevatedButton(
                      onPressed: () => context.read<InfiniteScrollShard>().loadMore(),
                      child: const Text('Retry'),
                    ),
                  ],
                ),
              );
            }

            return Column(
              children: [
                Padding(
                  padding: const EdgeInsets.all(12),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceAround,
                    children: [
                      Text('Items: ${state.items.length}'),
                      Text('Page: ${state.page}'),
                      Text('Has more: ${state.hasMore}'),
                    ],
                  ),
                ),
                Expanded(
                  child: ListView.builder(
                    controller: _scrollController,
                    itemCount: state.items.length + (state.hasMore ? 1 : 0),
                    itemBuilder: (context, index) {
                      if (index >= state.items.length) {
                        return Padding(
                          padding: const EdgeInsets.all(16),
                          child: Center(
                            child: state.isLoading
                                ? const CircularProgressIndicator()
                                : const SizedBox.shrink(),
                          ),
                        );
                      }
                      final item = state.items[index];
                      return ListTile(
                        title: Text(item.title),
                        leading: CircleAvatar(child: Text('${index + 1}')),
                      );
                    },
                  ),
                ),
                if (state.error != null)
                  Container(
                    width: double.infinity,
                    padding: const EdgeInsets.all(12),
                    color: Colors.red.shade100,
                    child: Row(
                      children: [
                        Icon(Icons.error, color: Colors.red.shade700, size: 20),
                        const SizedBox(width: 8),
                        Expanded(child: Text(state.error!)),
                        TextButton(
                          onPressed: () => context.read<InfiniteScrollShard>().loadMore(),
                          child: const Text('Retry'),
                        ),
                      ],
                    ),
                  ),
              ],
            );
          },
        ),
      ),
    );
  }
}

Summary:

  • ScrollController – listener calls loadMore() when pixels >= maxScrollExtent * 0.8.
  • ShardBuilder – one builder with full state; handle empty+loading, empty+error, and list + footer.
  • ThrottleloadMore() is safe to call on every scroll; the shard ensures the real load runs at most once per second.
  • context.read – used in scroll listener, retry, and reset so actions don’t register a build.

What we used

ConceptWhere
throttleIn loadMore() so rapid scrolls don’t trigger too many fetches.
ShardBuilderFull state for list, loading, and error UI.
ShardSelectorThrottle count in the app bar.
ScrollControllerListen to scroll position and call loadMore() near the bottom.

How it works

  1. User scrolls; when they pass 80% of the list, the listener calls loadMore().
  2. The shard’s loadMore() runs the real fetch only if the last run was more than 1 second ago (throttle).
  3. If throttled, we only increment throttleCount so the UI can show feedback.
  4. When the fetch runs, we set loading, then update items/page/hasMore or error.
  5. The list shows items and a footer loading indicator when hasMore and loading; errors can be shown at the bottom with a retry button.

Next Steps

Next: Todo App for persistence with PersistentShard. See also: Best Practices.