Working with Streams and StreamBuilder in Flutter

Working with Streams and StreamBuilder in Flutter

What You’ll Learn

Understand Dart Streams and how to use StreamBuilder to create reactive UIs that automatically update when data changes. This is crucial for real-time features like chat apps, live feeds, or any continuously updating data.

Streams vs Futures: The Key Difference

Think of a Stream as a pipe where data flows continuously. You can listen to it and react whenever new data arrives.

Example: Real-Time Counter with Stream

Here’s a simple countdown timer using Streams:

import 'dart:async';
import 'package:flutter/material.dart';

class CountdownTimer extends StatefulWidget {
  @override
  State<CountdownTimer> createState() => _CountdownTimerState();
}

class _CountdownTimerState extends State<CountdownTimer> {
  // Create a stream that emits countdown values
  Stream<int> countdownStream(int start) async* {
    for (int i = start; i >= 0; i--) {
      await Future.delayed(Duration(seconds: 1));
      yield i; // 'yield' sends a value to the stream
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Stream Countdown')),
      body: Center(
        child: StreamBuilder<int>(
          stream: countdownStream(10), // Our stream
          initialData: 10, // Show this while waiting
          builder: (context, snapshot) {
            // Handle different stream states
            if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            }

            if (snapshot.connectionState == ConnectionState.done) {
              return Text(
                'Blast off! 🚀',
                style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
              );
            }

            return Text(
              '${snapshot.data}',
              style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
            );
          },
        ),
      ),
    );
  }
}

Understanding ConnectionState

StreamBuilder provides connection state to track stream lifecycle:

ConnectionState.none      // No stream connected yet
ConnectionState.waiting   // Waiting for first value
ConnectionState.active    // Stream is emitting values
ConnectionState.done      // Stream completed

Example: Live Search with Debouncing

A practical real-world use case—search that updates as you type:

import 'dart:async';
import 'package:flutter/material.dart';

class LiveSearchScreen extends StatefulWidget {
  @override
  State<LiveSearchScreen> createState() => _LiveSearchScreenState();
}

class _LiveSearchScreenState extends State<LiveSearchScreen> {
  final _searchController = StreamController<String>();
  late Stream<List<String>> _resultsStream;

  @override
  void initState() {
    super.initState();

    // Transform input stream with debouncing
    _resultsStream = _searchController.stream
        .debounceTime(Duration(milliseconds: 500)) // Wait 500ms after typing stops
        .distinct() // Skip if same as previous query
        .asyncMap((query) => _performSearch(query)); // Async search
  }

  Future<List<String>> _performSearch(String query) async {
    // Simulate API call
    await Future.delayed(Duration(milliseconds: 300));

    if (query.isEmpty) return [];

    // Mock search results
    final allItems = [
      'Flutter', 'Dart', 'Provider', 'Riverpod',
      'Bloc', 'GetX', 'Firebase', 'StreamBuilder'
    ];

    return allItems
        .where((item) => item.toLowerCase().contains(query.toLowerCase()))
        .toList();
  }

  @override
  void dispose() {
    _searchController.close(); // Always close streams!
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Live Search')),
      body: Column(
        children: [
          Padding(
            padding: EdgeInsets.all(16),
            child: TextField(
              decoration: InputDecoration(
                labelText: 'Search',
                prefixIcon: Icon(Icons.search),
                border: OutlineInputBorder(),
              ),
              onChanged: (value) => _searchController.add(value),
            ),
          ),
          Expanded(
            child: StreamBuilder<List<String>>(
              stream: _resultsStream,
              initialData: [],
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.waiting) {
                  return Center(child: CircularProgressIndicator());
                }

                final results = snapshot.data ?? [];

                if (results.isEmpty) {
                  return Center(child: Text('No results found'));
                }

                return ListView.builder(
                  itemCount: results.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(results[index]),
                      leading: Icon(Icons.flutter_dash),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

// Extension to add debouncing to streams
extension StreamExtensions<T> on Stream<T> {
  Stream<T> debounceTime(Duration duration) {
    return transform(
      StreamTransformer.fromHandlers(
        handleData: (data, sink) {
          Timer? timer;
          timer?.cancel();
          timer = Timer(duration, () => sink.add(data));
        },
      ),
    );
  }
}

Stream Types

Single-Subscription Stream: Only one listener allowed (default)

final stream = Stream.periodic(Duration(seconds: 1), (count) => count);

Broadcast Stream: Multiple listeners allowed

final broadcastStream = stream.asBroadcastStream();

Try It Yourself

Build a real-time data dashboard:

  1. Create a stream that emits random temperature readings every 2 seconds
  2. Display the data using StreamBuilder
  3. Add min/max/average calculations that update live
  4. Show a line chart that updates with each new value

Challenge: Build a chat message list that listens to a Firestore stream and automatically scrolls to the bottom when new messages arrive. Use StreamBuilder combined with ScrollController.

Tip of the Day

Memory Management: Always close StreamController instances in dispose() to prevent memory leaks. For streams from external sources (like Firebase), the subscription is automatically managed by StreamBuilder.

Performance: Avoid creating new streams in the build method. Initialize streams in initState() or use a state management solution. Creating streams in build causes them to restart on every rebuild!