SmartList
A Flutter package that takes the headache out of building lists. Pagination, search, pull-to-refresh, caching, retries, and clean loading/error states β all wired up for you in two lines of code.
Why SmartList?
If you've ever built a list screen in Flutter, you've probably written the same boilerplate again and again:
- Load more when the user scrolls near the bottom.
- Debounce the search box so we don't hit the API on every keystroke.
- Show a spinner the first time, a small spinner at the bottom afterwards
- What if the user pulls to refresh while a search is loading?
- What if a slow response comes back after a faster one?
Every list screen ends up reinventing this. SmartList does it once, properly, and lets you focus on your UI.
What you get
| Feature | What it means |
|---|---|
| π Pagination | Page-based, cursor-based, or offset-based β pick one, swap any time |
| π Search | Built-in debouncing, automatic cancellation, restores previous list when you clear |
| β¬οΈ Pull-to-refresh | One flag β enableRefresh: true |
| π¨ UI states | Loading, empty, error, search-empty β all customisable, sensible defaults out of the box |
| πΎ Caching | In-memory cache with TTL & LRU; pluggable for disk caches later |
| π Auto-retry | Configurable exponential backoff with jitter |
| π‘οΈ Race-safe | Old responses can never overwrite newer ones |
| π§Ή No duplicates | Optional uniqueKey collapses duplicates across pages |
| πͺΆ Tiny | One dependency: Flutter itself. No third-party state-management library required |
Installation
Add to your pubspec.yaml:
dependencies:
smart_list: ^0.0.1
Then run:
flutter pub get
A note on the examples
SmartListis generic over any typeTβ bring your own model class. The examples below usePost,Message,Product, andUseras stand-ins for your domain types. Anywhere you see one of those, mentally substitute your own class.
Quickstart β 2 lines, really
import 'package:smart_list/smart_list.dart';
// `Post` here is your own model class.
final controller = SmartListController<Post>.simple(
fetcher: (req) async => SmartListPage(items: await api.getPosts(req.page)),
);
SmartListView<Post>(
controller: controller,
itemBuilder: (context, post, index) => ListTile(title: Text(post.title)),
);
That's it. You now have a list with infinite scroll, pull-to-refresh, loading/empty/error states, and caching.
Adding search
TextField(
onChanged: controller.search, // built-in debounce
decoration: InputDecoration(hintText: 'Searchβ¦'),
);
// Anywhere later:
controller.clearSearch(); // restores the original list
The fetcher receives the query through req.query β handle it however your API expects.
Customising the UI
Every state has a sensible default and is overridable. (Example below uses Product β your own model class.)
SmartListView<Product>(
controller: controller,
itemBuilder: (_, product, __) => ProductCard(product),
loadingBuilder: (_) => MyShimmerSkeleton(),
emptyBuilder: (_) => Center(child: Text('No products in stock')),
searchEmptyBuilder:(_, q) => Text('Nothing matches "$q"'),
errorBuilder: (_, err, retry) => MyErrorWidget(err, onRetry: retry),
loadingMoreBuilder:(_) => MySmallSpinner(),
separatorBuilder: (_, __) => const Divider(height: 1),
loadMoreThreshold: 300, // start prefetching 300px before the bottom
enableRefresh: true,
);
Need complete control? Skip SmartListView and use the controller directly β it's a ValueNotifier, so it works with any UI you like:
ValueListenableBuilder<SmartListState<Product>>(
valueListenable: controller,
builder: (context, state, _) {
if (state.isInitialLoading) return MyCustomSkeleton();
return CustomScrollView(slivers: [...]);
},
);
Pagination styles
Pick whichever your backend uses:
// Page-based: ?page=1&size=20 (this is the default in `.simple`)
PagePaginationStrategy<MyItem>(pageSize: 20)
// Cursor-based: ?cursor=xyz
CursorPaginationStrategy<MyItem>(pageSize: 20)
// Offset-based: ?offset=40&limit=20
OffsetPaginationStrategy<MyItem>(pageSize: 20)
Use the full constructor when you want a non-default strategy:
SmartListController<MyItem>(
fetcher: api.fetch,
strategyBuilder: () => CursorPaginationStrategy<MyItem>(pageSize: 30),
);
Real-time updates
Got a new chat message? An item that changed? Mutate the list directly β no refetch needed:
// In a chat screen with `SmartListController<Message>`:
controller.insertAtTop(newMessage);
controller.insertAtIndex(3, replyMessage);
controller.updateWhere((m) => m.id == 7, (m) => m.copyWith(read: true));
controller.removeWhere((m) => m.archived);
Filters
Re-fetch with new filters in one call:
controller.applyFilters({'status': 'open', 'category': 'food'});
Pass an empty map to clear them. The fetcher gets them through req.filters.
Plays well with every state-management library
SmartListController is a plain ValueNotifier β Provider, Riverpod, GetX, BLoC, and setState all consume it without an adapter. The only rule: dispose it when its scope dies.
// Provider
ChangeNotifierProvider(create: (_) => SmartListController.simple(...));
// Riverpod
final ctrlProvider = Provider.autoDispose((ref) {
final c = SmartListController.simple(...);
ref.onDispose(c.dispose);
return c;
});
// GetX
class HomeController extends GetxController {
final list = SmartListController.simple(...);
@override void onClose() { list.dispose(); super.onClose(); }
}
What the controller exposes
controller.loadInitial(); // first load (no-op if already loaded)
controller.loadNextPage(); // fetch next page
controller.refresh(); // pull-to-refresh (skips cache by default)
controller.refresh(bypassCache: false); // allow cache reuse on refresh
controller.search('flutter'); // debounced search
controller.clearSearch(); // restore pre-search list
controller.applyFilters({...}); // change filters & refetch
controller.insertAtTop(item);
controller.insertAtIndex(i, item);
controller.updateWhere(test, fn);
controller.removeWhere(test);
controller.reset(); // wipe state
controller.clearCache();
controller.value; // current SmartListState<T>
controller.addListener(() => β¦); // it's a ChangeNotifier
The state object
SmartListState<T> is what your UI reacts to:
state.items // List<T> β what to render
state.phase // SmartListPhase β initial / loading / loadingMore / refreshing / success / error
state.isInitialLoading
state.isLoadingMore
state.isRefreshing
state.hasError
state.error // Object?
state.isEmpty
state.isSearchActive
state.isSearchEmpty
state.hasReachedEnd
state.query // active search query
state.filters
It's immutable β every change produces a new instance. Equality is value-based, so you can drop it straight into BlocBuilder, Selector, etc.
Try the example
A full working demo lives in example/:
cd example
flutter run
It shows: pagination, debounced search, pull-to-refresh, simulated network errors with auto-retry, and a custom empty state.
Roadmap
Disk cache implementation (Hive / SQLite)Sliver-native variant forCustomScrollViewintegratorsHybrid local + remote searchFlutter DevTools timeline integration
PRs welcome.
License
MIT β use it freely in commercial and open-source projects.
Libraries
- smart_list
- SmartList β a unified, production-ready abstraction for paginated, searchable, cached lists in Flutter.