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:

  1. Create a stream that fetches price data every 5 seconds
  2. Use StreamBuilder to display the current price
  3. Show a loading indicator between updates
  4. 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: