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
- Future: Returns a single value once (like an HTTP request)
- Stream: Returns multiple values over time (like a live feed)
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:
- Create a stream that emits random temperature readings every 2 seconds
- Display the data using
StreamBuilder - Add min/max/average calculations that update live
- 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!