Advanced State Management (Bloc & Riverpod)

Handling Async Data with AsyncValue

16 min Lesson 9 of 14

Handling Async Data with AsyncValue

One of Riverpod's most powerful features is AsyncValue, a sealed class that elegantly models the three states of any asynchronous operation: loading, data, and error. Instead of manually managing isLoading boolean flags and nullable error objects, AsyncValue forces you to handle every possible state in a type-safe, exhaustive way.

When you fetch data from a REST API, read a file, or query a database, the operation can be in one of exactly three states at any given moment. AsyncValue encodes this reality directly into the type system, making impossible states unrepresentable.

Note: AsyncValue is a sealed class in Riverpod 2.x. It has three concrete subtypes: AsyncLoading, AsyncData<T>, and AsyncError. When you pattern-match with .when(), the Dart compiler can verify you have handled all three cases.

The Three States of AsyncValue

Every AsyncValue<T> is exactly one of the following at any instant:

  • AsyncLoading — the async operation is in progress; no data or error is available yet.
  • AsyncData<T> — the operation completed successfully and holds a value of type T.
  • AsyncError — the operation failed; holds the exception and its stack trace.

FutureProvider: Fetching Remote Data

FutureProvider is the simplest way to wrap a Future in Riverpod. You define it once at the top level, and Riverpod automatically wraps the result in an AsyncValue. Consumers watch it and rebuild whenever the state transitions between loading, data, and error.

FutureProvider Example — Fetching a User Profile

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

// Domain model
class UserProfile {
  final int id;
  final String name;
  final String email;

  const UserProfile({
    required this.id,
    required this.name,
    required this.email,
  });

  factory UserProfile.fromJson(Map<String, dynamic> json) {
    return UserProfile(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }
}

// FutureProvider — returns AsyncValue<UserProfile> automatically
final userProfileProvider = FutureProvider.family<UserProfile, int>((ref, userId) async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'),
  );

  if (response.statusCode != 200) {
    throw Exception('Failed to load user: HTTP ${response.statusCode}');
  }

  return UserProfile.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
});

Consuming AsyncValue with .when()

The primary way to render an AsyncValue in the UI is the .when() method. It requires callbacks for all three states, ensuring you never forget to handle loading or error scenarios. This is the idiomatic Riverpod pattern for async UI.

Consuming a FutureProvider with .when()

class UserProfileScreen extends ConsumerWidget {
  final int userId;

  const UserProfileScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Watch the provider — automatically gets AsyncValue<UserProfile>
    final asyncUser = ref.watch(userProfileProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('User Profile')),
      body: asyncUser.when(
        // Loading state — show a skeleton or spinner
        loading: () => const Center(
          child: CircularProgressIndicator(),
        ),
        // Error state — show a friendly error message with retry
        error: (error, stackTrace) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.error_outline, size: 48, color: Colors.red),
              const SizedBox(height: 16),
              Text(
                'Failed to load profile:\n$error',
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () => ref.invalidate(userProfileProvider(userId)),
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
        // Data state — render the actual content
        data: (user) => ListView(
          padding: const EdgeInsets.all(16),
          children: [
            CircleAvatar(
              radius: 40,
              child: Text(
                user.name[0].toUpperCase(),
                style: const TextStyle(fontSize: 32),
              ),
            ),
            const SizedBox(height: 16),
            ListTile(title: const Text('Name'), subtitle: Text(user.name)),
            ListTile(title: const Text('Email'), subtitle: Text(user.email)),
            ListTile(title: const Text('ID'), subtitle: Text('${user.id}')),
          ],
        ),
      ),
    );
  }
}

AsyncNotifier: Async State with Actions

When you need both async data and the ability to perform mutations (create, update, delete), use AsyncNotifier. It extends Notifier and wraps its state in an AsyncValue, giving you full control over state transitions.

AsyncNotifier — Posts Feed with Refresh

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,
      );
}

// AsyncNotifier automatically wraps state in AsyncValue<List<Post>>
class PostsNotifier extends AsyncNotifier<List<Post>> {
  @override
  Future<List<Post>> build() async {
    // This Future is automatically wrapped in AsyncValue
    return _fetchPosts();
  }

  Future<List<Post>> _fetchPosts() async {
    final response = await http.get(
      Uri.parse('https://jsonplaceholder.typicode.com/posts'),
    );
    final List<dynamic> json = jsonDecode(response.body) as List<dynamic>;
    return json
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  // Action: manually refresh the list
  Future<void> refresh() async {
    state = const AsyncLoading();  // Back to loading state
    state = await AsyncValue.guard(_fetchPosts);
  }

  // Action: add a new post optimistically
  Future<void> addPost(String title, String body) async {
    // Keep existing data visible while the request is in flight
    final previous = state;
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final newPost = Post(
        id: DateTime.now().millisecondsSinceEpoch,
        title: title,
        body: body,
      );
      // Return new list with the post prepended
      return [newPost, ...previous.valueOrNull ?? []];
    });
  }
}

final postsProvider = AsyncNotifierProvider<PostsNotifier, List<Post>>(
  PostsNotifier.new,
);
Tip: AsyncValue.guard() is a static helper that executes a Future-returning callback and wraps the result in AsyncData on success or AsyncError on exception. It eliminates the boilerplate of manual try/catch when setting notifier state.

Displaying Loading Skeletons

A skeleton screen is a placeholder UI that mimics the shape of real content while data loads. Riverpod's AsyncValue.loading callback is the natural hook for this pattern. Use a Shimmer effect (with the shimmer package) or simple grey containers to communicate progress without a blocking spinner.

Warning: Avoid wrapping your entire page in a CircularProgressIndicator for every load. This creates a jarring full-screen blank state on every navigation. Prefer skeleton screens or keeping previous data visible (skipLoadingOnRefresh: true on .when()) when refreshing already-loaded content.

.whenOrNull() and .maybeWhen() Variants

Sometimes you only need to react to one or two states. Riverpod provides additional pattern-matching helpers:

  • .whenData() — transforms only the data value; loading and error pass through unchanged.
  • .maybeWhen(orElse: ...) — handle some states with a fallback for unhandled ones.
  • .whenOrNull() — returns null for states you do not handle.
  • .valueOrNull — property that returns the data value or null if loading or error.
  • .isLoading, .hasError, .hasValue — boolean convenience getters.

Summary

AsyncValue is Riverpod's answer to the classic async UI problem: tracking loading, success, and failure in a type-safe, ergonomic way. Use FutureProvider for read-only async data, AsyncNotifier when you also need mutations, and always pattern-match with .when() to guarantee exhaustive state handling. Pair the loading state with skeleton screens for a polished user experience that avoids blank flashes.