State Management Fundamentals

FutureProvider & StreamProvider

45 min Lesson 11 of 14

Asynchronous State with Provider

Many real-world scenarios involve data that is not immediately available: fetching user profiles from an API, reading configuration from local storage, or listening to real-time database updates. Provider offers two specialized providers for these asynchronous patterns: FutureProvider for one-time async operations and StreamProvider for continuous reactive data.

Key Concept: Both FutureProvider and StreamProvider automatically handle the three states of asynchronous data: loading (waiting for data), data (value received), and error (something went wrong). Your widgets receive the appropriate state and can render accordingly.

FutureProvider: One-Time Async Initialization

FutureProvider wraps a Future and provides its resolved value to the widget tree. It is ideal for data that needs to be fetched once when a screen or app loads, such as user settings, remote configuration, or initial API data.

Basic FutureProvider

// Service that fetches data asynchronously
class ConfigService {
  Future<AppConfig> loadConfig() async {
    // Simulate network request
    await Future.delayed(const Duration(seconds: 2));
    return AppConfig(
      apiBaseUrl: 'https://api.example.com',
      maxRetries: 3,
      cacheTimeout: const Duration(minutes: 5),
    );
  }
}

// Provide the future result to the widget tree
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return FutureProvider<AppConfig?>(
      create: (_) => ConfigService().loadConfig(),
      initialData: null, // Value while the future is loading
      catchError: (context, error) => null, // Handle errors
      child: const MaterialApp(home: HomePage()),
    );
  }
}

// Consume the provided value
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    final config = context.watch<AppConfig?>();

    if (config == null) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    return Scaffold(
      appBar: AppBar(title: const Text('Home')),
      body: Center(
        child: Text('API URL: \${config.apiBaseUrl}'),
      ),
    );
  }
}

When the widget tree first builds, config will be null (the initial data). Once the Future completes, Provider automatically notifies all listeners, and the widget rebuilds with the loaded AppConfig object.

Warning: FutureProvider only calls the create function once. If you need to re-fetch data (e.g., pull-to-refresh), FutureProvider is not the right choice. Use a ChangeNotifier with a manual fetch method instead, or combine FutureProvider with ProxyProvider to trigger re-creation when dependencies change.

FutureProvider with Error Handling

Proper error handling is critical for async providers. The catchError parameter lets you provide a fallback value when the future fails:

FutureProvider Error Handling

class UserService {
  Future<User> fetchCurrentUser() async {
    final response = await http.get(
      Uri.parse('https://api.example.com/me'),
    );
    if (response.statusCode != 200) {
      throw HttpException('Failed to load user: \${response.statusCode}');
    }
    return User.fromJson(jsonDecode(response.body));
  }
}

// With explicit error handling
FutureProvider<User?>(
  create: (_) => UserService().fetchCurrentUser(),
  initialData: null,
  catchError: (context, error) {
    // Log the error
    debugPrint('Error loading user: \$error');
    // Return fallback value
    return null;
  },
  child: const MyApp(),
)

// Consumer checks for null to handle loading/error
class UserGreeting extends StatelessWidget {
  const UserGreeting({super.key});

  @override
  Widget build(BuildContext context) {
    final user = context.watch<User?>();

    if (user == null) {
      return const Text('Loading user...');
    }

    return Text('Welcome back, \${user.name}!');
  }
}

StreamProvider: Reactive Real-Time Data

StreamProvider wraps a Stream and provides the latest emitted value to the widget tree. Every time the stream emits a new value, all listening widgets rebuild automatically. This is perfect for real-time features like chat messages, live prices, sensor data, or database change streams.

Basic StreamProvider

// A stream that emits the current time every second
Stream<DateTime> clockStream() {
  return Stream.periodic(
    const Duration(seconds: 1),
    (_) => DateTime.now(),
  );
}

// Provide the stream to the widget tree
class ClockApp extends StatelessWidget {
  const ClockApp({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamProvider<DateTime>(
      create: (_) => clockStream(),
      initialData: DateTime.now(),
      child: const MaterialApp(home: ClockPage()),
    );
  }
}

// Widget automatically updates every second
class ClockPage extends StatelessWidget {
  const ClockPage({super.key});

  @override
  Widget build(BuildContext context) {
    final now = context.watch<DateTime>();

    return Scaffold(
      body: Center(
        child: Text(
          '\${now.hour.toString().padLeft(2, '0')}'
          ':\${now.minute.toString().padLeft(2, '0')}'
          ':\${now.second.toString().padLeft(2, '0')}',
          style: const TextStyle(fontSize: 48),
        ),
      ),
    );
  }
}

StreamProvider with Real-Time Messages

A common use case for StreamProvider is real-time messaging. Here is a practical example that listens to a chat stream:

Real-Time Chat with StreamProvider

class ChatService {
  // Returns a stream of messages from a WebSocket or Firestore
  Stream<List<Message>> getMessages(String chatRoomId) {
    // Example: Firestore real-time listener
    return FirebaseFirestore.instance
        .collection('chatRooms')
        .doc(chatRoomId)
        .collection('messages')
        .orderBy('timestamp', descending: true)
        .limit(50)
        .snapshots()
        .map((snapshot) => snapshot.docs
            .map((doc) => Message.fromFirestore(doc))
            .toList());
  }
}

// Provide the message stream
class ChatRoomPage extends StatelessWidget {
  final String roomId;
  const ChatRoomPage({super.key, required this.roomId});

  @override
  Widget build(BuildContext context) {
    return StreamProvider<List<Message>>(
      create: (_) => ChatService().getMessages(roomId),
      initialData: const [],
      catchError: (_, error) {
        debugPrint('Chat error: \$error');
        return const [];
      },
      child: const ChatRoomView(),
    );
  }
}

class ChatRoomView extends StatelessWidget {
  const ChatRoomView({super.key});

  @override
  Widget build(BuildContext context) {
    final messages = context.watch<List<Message>>();

    return Scaffold(
      appBar: AppBar(title: const Text('Chat')),
      body: ListView.builder(
        reverse: true,
        itemCount: messages.length,
        itemBuilder: (context, index) {
          final msg = messages[index];
          return MessageBubble(
            text: msg.text,
            isMe: msg.senderId == currentUserId,
            timestamp: msg.timestamp,
          );
        },
      ),
    );
  }
}

Loading and Error States

A robust approach to handling loading and error states is to use a wrapper class that explicitly tracks the async state. While Provider does not include an AsyncValue class built in, you can easily create one:

AsyncValue Pattern

// Generic async state wrapper
sealed class AsyncValue<T> {
  const AsyncValue();
}

class AsyncLoading<T> extends AsyncValue<T> {
  const AsyncLoading();
}

class AsyncData<T> extends AsyncValue<T> {
  final T value;
  const AsyncData(this.value);
}

class AsyncError<T> extends AsyncValue<T> {
  final Object error;
  final StackTrace? stackTrace;
  const AsyncError(this.error, [this.stackTrace]);
}

// ChangeNotifier that uses AsyncValue
class UserProfileNotifier extends ChangeNotifier {
  final ApiService _api;
  AsyncValue<UserProfile> _state = const AsyncLoading();

  UserProfileNotifier({required ApiService api}) : _api = api;

  AsyncValue<UserProfile> get state => _state;

  Future<void> loadProfile() async {
    _state = const AsyncLoading();
    notifyListeners();

    try {
      final data = await _api.get('profile');
      _state = AsyncData(UserProfile.fromJson(data));
    } catch (e, st) {
      _state = AsyncError(e, st);
    }

    notifyListeners();
  }

  Future<void> refresh() => loadProfile();
}

// Widget that handles all three states
class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context) {
    final state = context.select<UserProfileNotifier, AsyncValue<UserProfile>>(
      (n) => n.state,
    );

    return switch (state) {
      AsyncLoading() => const Center(
          child: CircularProgressIndicator(),
        ),
      AsyncData(:final value) => ProfileView(profile: value),
      AsyncError(:final error) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Error: \$error'),
              ElevatedButton(
                onPressed: () =>
                    context.read<UserProfileNotifier>().refresh(),
                child: const Text('Retry'),
              ),
            ],
          ),
        ),
    };
  }
}
Tip: The sealed class pattern with Dart 3’s exhaustive switch expressions ensures you handle every possible state. The compiler will warn you if you forget to handle loading, data, or error cases.

Combining with Other Providers

You can combine FutureProvider and StreamProvider with ProxyProvider to create async providers that depend on other values. When the dependency changes, the async provider automatically re-creates its future or stream.

StreamProvider with Dependencies

MultiProvider(
  providers: [
    // Auth provider
    ChangeNotifierProvider<AuthModel>(
      create: (_) => AuthModel(),
    ),

    // Database service depends on auth
    ProxyProvider<AuthModel, DatabaseService>(
      update: (_, auth, __) => DatabaseService(userId: auth.userId),
    ),

    // Stream of user orders — re-subscribes when DatabaseService changes
    StreamProvider<List<Order>>(
      create: (context) =>
          context.read<DatabaseService>().watchOrders(),
      initialData: const [],
      catchError: (_, __) => const [],
    ),

    // Future config load — loaded once at startup
    FutureProvider<RemoteConfig?>(
      create: (_) => RemoteConfigService().fetchConfig(),
      initialData: null,
      catchError: (_, __) => null,
    ),
  ],
  child: const MyApp(),
)

Practical Example: User Dashboard with Async Data

Let’s build a complete dashboard that loads user data asynchronously, streams real-time notifications, and handles all loading and error states:

Complete Async Dashboard

// Services
class NotificationService {
  Stream<List<AppNotification>> watchNotifications(String userId) {
    return FirebaseFirestore.instance
        .collection('notifications')
        .where('userId', isEqualTo: userId)
        .where('read', isEqualTo: false)
        .orderBy('createdAt', descending: true)
        .snapshots()
        .map((snap) => snap.docs
            .map((d) => AppNotification.fromFirestore(d))
            .toList());
  }
}

class SettingsService {
  Future<UserSettings> loadSettings() async {
    final prefs = await SharedPreferences.getInstance();
    return UserSettings(
      darkMode: prefs.getBool('darkMode') ?? false,
      language: prefs.getString('language') ?? 'en',
      notifications: prefs.getBool('notifications') ?? true,
    );
  }
}

// App setup with multiple async providers
class DashboardApp extends StatelessWidget {
  const DashboardApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        // Auth (immediate)
        ChangeNotifierProvider<AuthModel>(
          create: (_) => AuthModel(),
          lazy: false,
        ),

        // Settings (future — loads once)
        FutureProvider<UserSettings?>(
          create: (_) => SettingsService().loadSettings(),
          initialData: null,
          catchError: (_, __) => null,
        ),

        // Notifications (stream — real-time)
        StreamProvider<List<AppNotification>>(
          create: (context) {
            final auth = context.read<AuthModel>();
            if (auth.userId == null) return const Stream.empty();
            return NotificationService()
                .watchNotifications(auth.userId!);
          },
          initialData: const [],
          catchError: (_, __) => const [],
        ),
      ],
      child: const MaterialApp(home: DashboardPage()),
    );
  }
}

// Dashboard consuming all providers
class DashboardPage extends StatelessWidget {
  const DashboardPage({super.key});

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthModel>();
    final settings = context.watch<UserSettings?>();
    final notifications = context.watch<List<AppNotification>>();

    if (settings == null) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    return Scaffold(
      appBar: AppBar(
        title: Text('Hello, \${auth.currentUser?.name ?? "User"}'),
        actions: [
          Badge(
            label: Text('\${notifications.length}'),
            isLabelVisible: notifications.isNotEmpty,
            child: IconButton(
              icon: const Icon(Icons.notifications),
              onPressed: () => Navigator.pushNamed(
                context, '/notifications',
              ),
            ),
          ),
        ],
      ),
      body: Column(
        children: [
          // Settings loaded from future
          Text('Theme: \${settings.darkMode ? "Dark" : "Light"}'),
          Text('Language: \${settings.language}'),
          const Divider(),
          // Real-time notifications from stream
          Expanded(
            child: ListView.builder(
              itemCount: notifications.length,
              itemBuilder: (context, index) {
                final n = notifications[index];
                return ListTile(
                  leading: const Icon(Icons.notification_important),
                  title: Text(n.title),
                  subtitle: Text(n.message),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}
Summary: Use FutureProvider when you need to load data once (configuration, user settings, initial API call). Use StreamProvider when you need continuous real-time updates (chat messages, notifications, live prices). For complex async state that requires re-fetching, refresh capability, or pagination, use a ChangeNotifier with the AsyncValue pattern instead.