Series 2 of 2 — Part 2 of 4
Building DevFeed — Part 2: The Feed, Hacker News API, and Riverpod AsyncNotifier
Series: Building DevFeed — A Proper Flutter MVVM App from Day One
With the architecture in place from Part 1, it was time to build the actual feature — fetching Hacker News stories and displaying them. This post covers the repository implementation, the data fetching strategy, and why AsyncNotifier is the right Riverpod primitive for a feed with pull-to-refresh.
The Hacker News API
The API is public, free, and needs no authentication. The top stories endpoint returns an array of item IDs, and then you fetch each item individually for its details.
// Step 1: fetch top story IDs
GET https://hacker-news.firebaseio.com/v0/topstories.json
// Returns: [34562341, 34560198, ...]
// Step 2: fetch each item
GET https://hacker-news.firebaseio.com/v0/item/{id}.json
If you fetch all IDs and then fetch each item sequentially, loading the feed takes a long time. The fix is Future.wait for parallel fetching.
The repository with parallel fetching
class HackerNewsRepository {
final Dio _dio;
HackerNewsRepository({required Dio dio}) : _dio = dio;
Future<List<ArticleModel>> fetchTopStories({int limit = 30}) async {
final idsResponse = await _dio.get(
'https://hacker-news.firebaseio.com/v0/topstories.json'
);
final ids = (idsResponse.data as List).take(limit).cast<int>();
final articles = await Future.wait(ids.map((id) => _fetchItem(id)));
return articles.whereType<ArticleModel>().toList();
}
Future<ArticleModel?> _fetchItem(int id) async {
final response = await _dio.get(
'https://hacker-news.firebaseio.com/v0/item/$id.json'
);
if (response.data == null) return null;
return ArticleModel.fromJson(response.data);
}
}
I used Dio rather than the built-in http package for its interceptors, request cancellation, and cleaner error handling. The repository takes Dio in its constructor for easy testing.
AsyncNotifier vs FutureProvider
Riverpod offers two main options for async state. FutureProvider is simpler — define a function returning a Future, Riverpod handles the AsyncValue wrapping. AsyncNotifier gives you more control — you expose methods like refresh alongside the state.
For a feed with pull-to-refresh, AsyncNotifier is the right choice. The refresh method sets state to AsyncLoading then re-fetches, which drives the loading indicator in the UI.
The feed screen
The FeedScreen watches feedViewModelProvider and uses .when to render the correct UI:
final feedAsync = ref.watch(feedViewModelProvider);
return feedAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorWidget(e.toString()),
data: (articles) => ArticleList(articles: articles),
);
The screen has no knowledge of Dio, the API URL, or how articles are fetched. It just renders what the ViewModel provides.
What I learned
-
Parallelize I/O:
Future.waitfor parallel item fetching makes a real difference in feed load speed. -
Choose the right provider:
AsyncNotifieris the right choice when you need to expose methods alongside async state. - Inject HTTP clients: Injecting Dio through Riverpod centralises configuration and makes testing straightforward.
Next: Part 3 — In-app WebView, Sharing Actions, and the Android INTERNET Permission
Top comments (1)
Clean structure. Two small things I’d watch in the feed:
. Future.wait fails as a whole if any single item request throws, so one flaky story kills the entire feed. Catching inside _fetchItem and returning null (you already filter nulls with whereType) keeps the other 29.
. If refresh sets state = AsyncLoading() by hand, the list can be swapped for a spinner on every pull. With RefreshIndicator(onRefresh: () => ref.refresh(feedViewModelProvider.future)), Riverpod keeps the previous data while it reloads, and .when keeps showing the list since it skips loading on refresh by default.