Skip to content
ExponaBLOG
FLUTTER · DAY 065 / 100

StreamBuilder: what every Flutter developer should know

Render UI from a stream of events.

Expona Admin8 min read

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:

dart
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:

dart
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 yet
  • ConnectionState.waiting – Waiting for the first event
  • ConnectionState.active – Receiving data
  • ConnectionState.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:

dart
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:

dart
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:

dart
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:

dart
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:

dart
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:

dart
// 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:

dart
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 initState or a service layer.
  • Not handling all snapshot states: Forgetting to check for errors or loading states leaves users confused. Always cover hasError, waiting states, and hasData.
  • 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.connectionState and snapshot.hasError to check them.
  • Create streams once in service classes or initState, never inside build methods. 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles