Pagination
Infinite scroll with QueryReacton: paginated API fetching, load more, pull-to-refresh, and stale-while-revalidate.
Data Model
dart
import 'package:flutter/material.dart';
import 'package:flutter_reacton/flutter_reacton.dart';
class Post {
final int id;
final String title;
final String body;
const Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'] as int,
title: json['title'] as String,
body: json['body'] as String,
);
}
class PagedResponse<T> {
final List<T> items;
final int page;
final int totalPages;
final bool hasMore;
const PagedResponse({
required this.items,
required this.page,
required this.totalPages,
required this.hasMore,
});
}Simulated API
dart
class PostApi {
static Future<PagedResponse<Post>> fetchPosts({
required int page,
int pageSize = 20,
}) async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 800));
// Simulate 100 total posts
const totalPosts = 100;
final totalPages = (totalPosts / pageSize).ceil();
final startIndex = (page - 1) * pageSize;
final endIndex = (startIndex + pageSize).clamp(0, totalPosts);
final items = List.generate(
endIndex - startIndex,
(i) => Post(
id: startIndex + i + 1,
title: 'Post #${startIndex + i + 1}',
body: 'This is the body of post ${startIndex + i + 1}.',
),
);
return PagedResponse(
items: items,
page: page,
totalPages: totalPages,
hasMore: page < totalPages,
);
}
}Reacton Definitions
dart
/// Current page number.
final currentPageReacton = reacton(1, name: 'currentPage');
/// Accumulated list of all loaded posts.
final allPostsReacton = reacton<List<Post>>([], name: 'allPosts');
/// Whether there are more pages to load.
final hasMoreReacton = reacton(true, name: 'hasMore');
/// Whether a page is currently being fetched.
final isLoadingMoreReacton = reacton(false, name: 'isLoadingMore');
/// The initial page query using reactonQuery with caching.
final postsQuery = reactonQuery<PagedResponse<Post>>(
queryFn: (_) => PostApi.fetchPosts(page: 1),
config: QueryConfig(
staleTime: const Duration(minutes: 5),
cacheTime: const Duration(minutes: 30),
retryPolicy: RetryPolicy(maxAttempts: 3),
),
name: 'postsQuery',
);
/// Total loaded post count.
final postCountReacton = computed(
(read) => read(allPostsReacton).length,
name: 'postCount',
);Pagination Logic
dart
/// Load the initial page of posts.
Future<void> loadInitialPosts(ReactonStore store) async {
try {
final response = await store.fetchQuery(postsQuery);
store.batch(() {
store.set(allPostsReacton, response.items);
store.set(currentPageReacton, response.page);
store.set(hasMoreReacton, response.hasMore);
});
} catch (e) {
debugPrint('Failed to load posts: $e');
}
}
/// Load the next page of posts (append to existing list).
Future<void> loadMorePosts(ReactonStore store) async {
final isLoading = store.get(isLoadingMoreReacton);
final hasMore = store.get(hasMoreReacton);
if (isLoading || !hasMore) return;
store.set(isLoadingMoreReacton, true);
try {
final currentPage = store.get(currentPageReacton);
final nextPage = currentPage + 1;
final response = await PostApi.fetchPosts(page: nextPage);
store.batch(() {
store.update(allPostsReacton, (posts) => [...posts, ...response.items]);
store.set(currentPageReacton, nextPage);
store.set(hasMoreReacton, response.hasMore);
store.set(isLoadingMoreReacton, false);
});
} catch (e) {
store.set(isLoadingMoreReacton, false);
debugPrint('Failed to load more posts: $e');
}
}
/// Refresh: clear all posts and reload from page 1.
Future<void> refreshPosts(ReactonStore store) async {
store.batch(() {
store.set(allPostsReacton, <Post>[]);
store.set(currentPageReacton, 1);
store.set(hasMoreReacton, true);
});
// Invalidate the query to force a fresh fetch
await store.invalidateQuery(postsQuery);
await loadInitialPosts(store);
}UI Implementation
dart
void main() {
final store = ReactonStore();
runApp(ReactonScope(
store: store,
child: const PaginationApp(),
));
// Load initial data
loadInitialPosts(store);
}
class PaginationApp extends StatelessWidget {
const PaginationApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pagination Example',
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
home: const PostListPage(),
);
}
}
class PostListPage extends StatelessWidget {
const PostListPage({super.key});
@override
Widget build(BuildContext context) {
final posts = context.watch(allPostsReacton);
final hasMore = context.watch(hasMoreReacton);
final isLoadingMore = context.watch(isLoadingMoreReacton);
final queryState = context.watch(postsQuery);
final postCount = context.watch(postCountReacton);
return Scaffold(
appBar: AppBar(
title: Text('Posts ($postCount)'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => refreshPosts(context.reactonStore),
),
],
),
body: queryState.when(
loading: () {
if (posts.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
// Stale-while-revalidate: show existing posts while refreshing
return _PostList(
posts: posts,
hasMore: hasMore,
isLoadingMore: true,
);
},
data: (_) => _PostList(
posts: posts,
hasMore: hasMore,
isLoadingMore: isLoadingMore,
),
error: (error, _) {
if (posts.isNotEmpty) {
// Show posts with error banner
return Column(
children: [
MaterialBanner(
content: Text('Error: $error'),
actions: [
TextButton(
onPressed: () => refreshPosts(context.reactonStore),
child: const Text('Retry'),
),
],
),
Expanded(
child: _PostList(
posts: posts,
hasMore: hasMore,
isLoadingMore: false,
),
),
],
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $error'),
const SizedBox(height: 16),
FilledButton(
onPressed: () => refreshPosts(context.reactonStore),
child: const Text('Retry'),
),
],
),
);
},
),
);
}
}
class _PostList extends StatelessWidget {
final List<Post> posts;
final bool hasMore;
final bool isLoadingMore;
const _PostList({
required this.posts,
required this.hasMore,
required this.isLoadingMore,
});
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
// Load more when user scrolls near the bottom
if (notification is ScrollEndNotification) {
final metrics = notification.metrics;
if (metrics.pixels >= metrics.maxScrollExtent - 200) {
loadMorePosts(context.reactonStore);
}
}
return false;
},
child: ListView.builder(
itemCount: posts.length + (hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == posts.length) {
// Loading indicator at the bottom
return Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: isLoadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: () => loadMorePosts(context.reactonStore),
child: const Text('Load More'),
),
),
);
}
final post = posts[index];
return ListTile(
leading: CircleAvatar(child: Text('${post.id}')),
title: Text(post.title),
subtitle: Text(
post.body,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
),
);
}
}Key Concepts
QueryReacton with Caching
reactonQuery provides automatic caching with configurable staleTime and cacheTime. While data is fresh (within staleTime), re-fetches are skipped. When data becomes stale, the cached data is returned immediately while a background refetch occurs (stale-while-revalidate).
Infinite Scroll Pattern
- Track
currentPage,allPosts,hasMore, andisLoadingMoreas separate reactons loadMorePostschecks guards (not already loading, has more pages) before fetching- On success, append new items to the existing list using
batch() - A
ScrollNotificationlistener triggers loading when the user scrolls near the bottom
Pull-to-Refresh
refreshPosts clears all state, invalidates the query cache, and reloads from page 1. invalidateQuery forces the query to refetch regardless of stale time.
Error Handling
When errors occur but cached data exists, the UI shows the cached posts with an error banner. When no data exists, a full-screen error with a retry button is shown.
What's Next
- Offline-First -- Persistence and optimistic updates
- Todo App -- CRUD operations and filtering
- Authentication -- State machine patterns