This guide walks you through building an infinite list that:
We’ll do it in three steps: state model → shard (with throttle) → UI with scroll listener.
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});
}
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.throttle('loadMore', () => _performLoadMore(), duration: Duration(seconds: 1)).false; otherwise it runs and returns true.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,
));
}
}
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:
loadMore() when pixels >= maxScrollExtent * 0.8.state; handle empty+loading, empty+error, and list + footer.loadMore() is safe to call on every scroll; the shard ensures the real load runs at most once per second.| Concept | Where |
|---|---|
| throttle | In loadMore() so rapid scrolls don’t trigger too many fetches. |
| ShardBuilder | Full state for list, loading, and error UI. |
| ShardSelector | Throttle count in the app bar. |
| ScrollController | Listen to scroll position and call loadMore() near the bottom. |
loadMore().loadMore() runs the real fetch only if the last run was more than 1 second ago (throttle).throttleCount so the UI can show feedback.hasMore and loading; errors can be shown at the bottom with a retry button.Next: Todo App for persistence with PersistentShard. See also: Best Practices.