Mastering StreamBuilder: Real-Time Data in Flutter
Mastering StreamBuilder: Real-Time Data in Flutter
What You’ll Learn
Today we’ll explore StreamBuilder, Flutter’s widget for handling continuous data streams. Unlike FutureBuilder which handles one-time async operations, StreamBuilder listens to multiple values over time—perfect for real-time updates, websockets, or listening to database changes.
Understanding Streams
A Stream is like a pipe that delivers data over time. Think of it as a conveyor belt of values: each time new data arrives, your UI automatically updates.
Example
Here’s a practical timer app using StreamBuilder:
import 'package:flutter/material.dart';
import 'dart:async';
class TimerScreen extends StatefulWidget {
@override
State<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends State<TimerScreen> {
// Stream that emits values every second
Stream<int> _timerStream() {
return Stream.periodic(
Duration(seconds: 1),
(count) => count,
).take(60); // Stops after 60 seconds
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('StreamBuilder Timer')),
body: Center(
child: StreamBuilder<int>(
stream: _timerStream(),
builder: (context, snapshot) {
// Handle different connection states
if (snapshot.connectionState == ConnectionState.waiting) {
return Text('Starting timer...');
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
if (snapshot.connectionState == ConnectionState.done) {
return Text('Timer Complete!');
}
// Display the current value
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${snapshot.data ?? 0}',
style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
),
Text('seconds elapsed'),
],
);
},
),
),
);
}
}
Real-World Use Case: Search Debouncing
Here’s how to use streams for search input with debouncing:
class SearchScreen extends StatefulWidget {
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _searchController = StreamController<String>();
late Stream<String> _debouncedSearch;
@override
void initState() {
super.initState();
// Debounce: wait 500ms after user stops typing
_debouncedSearch = _searchController.stream
.distinct() // Skip duplicate values
.debounceTime(Duration(milliseconds: 500));
}
@override
void dispose() {
_searchController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (value) => _searchController.add(value),
decoration: InputDecoration(hintText: 'Search...'),
),
StreamBuilder<String>(
stream: _debouncedSearch,
builder: (context, snapshot) {
if (!snapshot.hasData) return SizedBox();
return Text('Searching for: ${snapshot.data}');
},
),
],
);
}
}
Note: For the debounce extension, add rxdart package or implement your own.
Try It Yourself
Build a live cryptocurrency price tracker:
- Create a stream that fetches price data every 5 seconds
- Use
StreamBuilderto display the current price - Show a loading indicator between updates
- Handle errors gracefully (network failures, etc.)
Tip of the Day
Stream Performance Tip: Always create streams in initState() or as a final variable, NOT in the build() method. Creating a new stream on every rebuild causes memory leaks and unexpected behavior.
Quick Debug: Use snapshot.connectionState to understand your stream’s lifecycle:
none: No connection yetwaiting: Listening but no dataactive: Receiving datadone: Stream closed