You're building a weather app and need to display live temperature updates every few seconds. The UI should refresh automatically when new data arrives, but you also need to handle loading states and potential errors gracefully. This is exactly where StreamBuilder comes in—a powerful Flutter widget that renders UI from a stream of events, turning asynchronous data into reactive, responsive interfaces.
Rebuilds When New Data Arrives
One of the core strengths of StreamBuilder is that it automatically listens to a stream and rebuilds the widget tree whenever new data flows through. Unlike futures that resolve once, streams emit multiple events over time, and StreamBuilder responds to each one.
Let me walk you through a practical example. Imagine you're tracking user activity in a multiplayer game. You have a stream emitting player position updates:
class GameLocationService {
final _locationController = StreamController<LatLng>();
Stream<LatLng> get locationStream => _locationController.stream;
void startTracking() {
Timer.periodic(Duration(milliseconds: 500), (timer) {
final newLat = Random().nextDouble() * 180 - 90;
final newLng = Random().nextDouble() * 360 - 180;
_locationController.add(LatLng(newLat, newLng));
});
}
}Now, in your UI, you use StreamBuilder to render this data in real time:
class PlayerMap extends StatelessWidget {
final GameLocationService locationService;
const PlayerMap({required this.locationService});
@override
Widget build(BuildContext context) {
return StreamBuilder<LatLng>(
stream: locationService.locationStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
final position = snapshot.data!;
return Center(
child: Text(
'Lat: ${position.latitude.toStringAsFixed(2)}, '
'Lng: ${position.longitude.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 18),
),
);
}
return const Text('Awaiting location...');
},
);
}
}Every time the stream emits a new LatLng, StreamBuilder detects it and calls the builder function, updating the displayed coordinates. This happens automatically without you manually calling setState.
Handle Waiting, Error and Data States
Real-world streams are unpredictable. They take time to establish, they might fail, or they might be empty. StreamBuilder provides the AsyncSnapshot object to handle all these scenarios gracefully.
The snapshot has a connectionState property that tells you what's happening:
ConnectionState.none– Stream hasn't started yetConnectionState.waiting– Waiting for the first eventConnectionState.active– Receiving dataConnectionState.done– Stream closed
You also have hasData, hasError, and data properties to check state more conveniently.
Consider a cryptocurrency price ticker. Your backend service emits price updates but might occasionally fail:
class CryptoPriceService {
final _priceController = StreamController<double>();
Stream<double> get priceStream => _priceController.stream;
void startPriceFeed() {
Timer.periodic(Duration(seconds: 2), (timer) async {
try {
// Simulate API call
await Future.delayed(Duration(milliseconds: 500));
if (Random().nextInt(10) > 7) {
_priceController.addError('Network error');
} else {
final price = 45000 + Random().nextInt(1000) - 500;
_priceController.add(price.toDouble());
}
} catch (e) {
_priceController.addError(e);
}
});
}
}Now build your UI to handle all states:
class PriceDisplay extends StatelessWidget {
final CryptoPriceService priceService;
const PriceDisplay({required this.priceService});
@override
Widget build(BuildContext context) {
return StreamBuilder<double>(
stream: priceService.priceStream,
builder: (context, snapshot) {
// Handle waiting state
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(
child: CircularProgressIndicator(),
);
}
// Handle error state
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 48),
const SizedBox(height: 16),
Text(
'Error: ${snapshot.error}',
style: const TextStyle(color: Colors.red),
),
],
),
);
}
// Handle data state
if (snapshot.hasData) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Bitcoin Price'),
Text(
'\$${snapshot.data!.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
// Initial state
return const Center(child: Text('No data'));
},
);
}
}By handling all states explicitly, you provide a smooth, professional experience where users see loading spinners, error messages, or fresh data depending on what's actually happening.
Don't Create the Stream Inside Build
This is a critical mistake that will burn your performance. Creating a new stream every time the widget rebuilds means your listeners are constantly being created and destroyed, defeating the purpose of reactive programming.
Here's the wrong approach:
class BadNotificationWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// WRONG: New stream created on every build
return StreamBuilder<String>(
stream: Stream.periodic(
Duration(seconds: 1),
(count) => 'Notification #$count',
),
builder: (context, snapshot) {
return Text(snapshot.data ?? 'Waiting...');
},
);
}
}Every rebuild creates a fresh stream and subscription. If your parent widget rebuilds for any reason, the old stream is abandoned and a new one starts from scratch. You'll lose data, see unnecessary loading states, and waste memory.
Instead, create the stream at a higher level—either in a stateful widget's init method, a service class, or through a state management solution:
class NotificationService {
late final Stream<String> _notificationStream;
Stream<String> get notificationStream {
_notificationStream ??= Stream.periodic(
Duration(seconds: 1),
(count) => 'Notification #$count',
);
return _notificationStream;
}
}Or in a stateful widget:
class GoodNotificationWidget extends StatefulWidget {
@override
State<GoodNotificationWidget> createState() =>
_GoodNotificationWidgetState();
}
class _GoodNotificationWidgetState extends State<GoodNotificationWidget> {
late Stream<String> _notificationStream;
@override
void initState() {
super.initState();
_notificationStream = Stream.periodic(
Duration(seconds: 1),
(count) => 'Notification #$count',
);
}
@override
Widget build(BuildContext context) {
return StreamBuilder<String>(
stream: _notificationStream,
builder: (context, snapshot) {
return Text(snapshot.data ?? 'Waiting...');
},
);
}
@override
void dispose() {
// Clean up if using a controller
super.dispose();
}
}By creating the stream once and reusing it, you maintain a persistent subscription that doesn't restart on every rebuild.
Prefer State Management for Complex Logic
StreamBuilder is excellent for simple, single-stream scenarios. But when you have multiple interdependent streams, complex business logic, or state that needs to be shared across many widgets, reaching for a proper state management solution keeps your code maintainable.
For instance, imagine a trading app where you need to:
- Listen to live price streams
- Track user portfolio changes
- Calculate profit/loss based on multiple data sources
- Handle user actions that modify state
Nesting multiple StreamBuilder widgets becomes messy quickly:
// Gets hard to read and maintain
StreamBuilder<List<Price>>(
stream: priceService.prices,
builder: (context, priceSnapshot) {
return StreamBuilder<Portfolio>(
stream: portfolioService.portfolio,
builder: (context, portfolioSnapshot) {
return StreamBuilder<User>(
stream: userService.user,
builder: (context, userSnapshot) {
// Now coordinate all three snapshots...
},
);
},
);
},
)With a provider or Riverpod setup, you abstract complexity:
final pricesProvider = StreamProvider<List<Price>>((ref) {
return priceService.getPriceStream();
});
final portfolioProvider = StreamProvider<Portfolio>((ref) {
return portfolioService.getPortfolioStream();
});
final profitLossProvider = FutureProvider<double>((ref) async {
final prices = await ref.watch(pricesProvider).first;
final portfolio = await ref.watch(portfolioProvider).first;
return calculateProfitLoss(prices, portfolio);
});
class TradingScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final profitLoss = ref.watch(profitLossProvider);
return profitLoss.when(
data: (value) => Text('P/L: $value'),
loading: () => const CircularProgressIndicator(),
error: (err, st) => Text('Error: $err'),
);
}
}State management libraries handle subscription cleanup, caching, and coordination automatically. They scale better as your app grows.
Common Mistakes
- Creating streams in build: Leading to constant subscription churn and memory leaks. Always initialize streams in
initStateor a service layer. - Not handling all snapshot states: Forgetting to check for errors or loading states leaves users confused. Always cover
hasError, waiting states, andhasData. - Forgetting to close StreamControllers: In your service classes, always close controllers in a dispose method or when the stream is no longer needed. Memory leaks will accumulate otherwise.
- Over-nesting StreamBuilders: Two or three nested StreamBuilder widgets is your signal to move to state management. The code becomes unreadable fast.
- Mixing synchronous and asynchronous logic: Keep your stream emissions pure. Don't add side effects like navigation or shared preferences updates inside the stream callback.
Key Takeaways
- StreamBuilder automatically rebuilds UI whenever the stream emits new data, making it perfect for real-time, event-driven interfaces.
- Always handle three states: waiting (loading), error, and data. Use
snapshot.connectionStateandsnapshot.hasErrorto check them. - Create streams once in service classes or
initState, never insidebuildmethods. Reuse the same stream across rebuilds. - For complex scenarios with multiple streams and business logic, invest in state management tools like Provider or Riverpod instead of nesting StreamBuilder widgets.
- Always dispose of StreamControllers properly to prevent memory leaks and unexpected behavior.






Comments
Loading comments…