Firebase Integration

Realtime Data Synchronization with Firestore Streams

16 min Lesson 7 of 13

Realtime Data Synchronization with Firestore Streams

Cloud Firestore provides a powerful mechanism called snapshots streams that lets your Flutter app receive live updates whenever data in the database changes. Instead of making a one-time fetch, you subscribe to a stream and Firestore pushes every change — creates, updates, deletions — directly to your widget in real time. This eliminates the need for manual polling and keeps your UI always in sync with the server.

Note: Firestore streams use the WebSocket protocol under the hood. When you call snapshots() on a query or document reference, the SDK opens a persistent connection that delivers change events until you cancel the subscription. On mobile, the SDK also buffers events for offline support, resuming the stream when connectivity is restored.

Subscribing to a Document Stream

Use DocumentReference.snapshots() to listen to a single document. The stream emits a DocumentSnapshot every time that document is written to, even if another field changes.

Document Snapshots Stream

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

class UserProfileWidget extends StatelessWidget {
  final String userId;
  const UserProfileWidget({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    // snapshots() returns a Stream<DocumentSnapshot>
    final Stream<DocumentSnapshot<Map<String, dynamic>>> stream =
        FirebaseFirestore.instance
            .collection('users')
            .doc(userId)
            .snapshots();

    return StreamBuilder<DocumentSnapshot<Map<String, dynamic>>>(
      stream: stream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const CircularProgressIndicator();
        }
        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }
        if (!snapshot.hasData || !snapshot.data!.exists) {
          return const Text('User not found.');
        }

        final data = snapshot.data!.data()!;
        return ListTile(
          title: Text(data['name'] ?? 'No name'),
          subtitle: Text(data['email'] ?? ''),
        );
      },
    );
  }
}

Subscribing to a Collection Stream

Use CollectionReference.snapshots() or Query.snapshots() to listen to multiple documents at once. The stream emits a QuerySnapshot that carries the entire result set plus a list of DocumentChange objects describing exactly what was added, modified, or removed since the last event.

Collection Snapshots Stream with Filtering

class MessagesWidget extends StatelessWidget {
  final String chatId;
  const MessagesWidget({super.key, required this.chatId});

  @override
  Widget build(BuildContext context) {
    // Query with ordering — returns Stream<QuerySnapshot>
    final stream = FirebaseFirestore.instance
        .collection('chats')
        .doc(chatId)
        .collection('messages')
        .orderBy('sentAt', descending: true)
        .limit(50)
        .snapshots();

    return StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
      stream: stream,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('Error: ${snapshot.error}'));
        }

        final docs = snapshot.data?.docs ?? [];

        if (docs.isEmpty) {
          return const Center(child: Text('No messages yet.'));
        }

        return ListView.builder(
          reverse: true,
          itemCount: docs.length,
          itemBuilder: (context, index) {
            final msg = docs[index].data();
            return ListTile(
              title: Text(msg['text'] ?? ''),
              subtitle: Text(msg['senderName'] ?? 'Unknown'),
            );
          },
        );
      },
    );
  }
}

StreamBuilder Connection States

The StreamBuilder widget rebuilds every time a new snapshot arrives. Its builder callback receives an AsyncSnapshot that exposes four connection states you must handle for a robust UI:

  • ConnectionState.none — no stream provided yet.
  • ConnectionState.waiting — stream opened, first event not yet received; show a loading indicator.
  • ConnectionState.active — stream is delivering events; snapshot.data is valid.
  • ConnectionState.done — stream closed; unlikely for Firestore unless you deliberately cancel.
Tip: Always check snapshot.hasError before accessing snapshot.data. Network errors, permission-denied events, and malformed documents surface here. Log them and show a user-friendly fallback rather than letting the app silently break.

Managing Subscription Lifecycle in StatefulWidgets

When you need to start listening imperatively — for example to react to a button press or to chain Firestore calls — use a StatefulWidget, store the StreamSubscription, and cancel it in dispose(). Failing to cancel leaks memory and may cause setState called after dispose crashes.

Manual Subscription with Proper Lifecycle Management

import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';

class LiveScoreBoard extends StatefulWidget {
  final String matchId;
  const LiveScoreBoard({super.key, required this.matchId});

  @override
  State<LiveScoreBoard> createState() => _LiveScoreBoardState();
}

class _LiveScoreBoardState extends State<LiveScoreBoard> {
  // Hold a reference so we can cancel in dispose()
  StreamSubscription<DocumentSnapshot>? _subscription;
  Map<String, dynamic>? _scoreData;
  String? _error;

  @override
  void initState() {
    super.initState();
    _subscribe();
  }

  void _subscribe() {
    _subscription = FirebaseFirestore.instance
        .collection('matches')
        .doc(widget.matchId)
        .snapshots()
        .listen(
          (snapshot) {
            if (!mounted) return; // Guard before setState
            setState(() {
              _scoreData = snapshot.data() as Map<String, dynamic>?;
              _error = null;
            });
          },
          onError: (Object e) {
            if (!mounted) return;
            setState(() {
              _error = e.toString();
            });
          },
        );
  }

  @override
  void dispose() {
    // CRITICAL: cancel the subscription to avoid memory leaks
    _subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return Text('Error: $_error');
    }
    if (_scoreData == null) {
      return const CircularProgressIndicator();
    }
    return Column(
      children: [
        Text('Home: ${_scoreData!['homeScore'] ?? 0}'),
        Text('Away: ${_scoreData!['awayScore'] ?? 0}'),
      ],
    );
  }
}
Warning: Never pass a Stream created inline (e.g. stream: ref.snapshots()) directly inside a build() method of a StatefulWidget — every rebuild creates a new stream, causing flicker, extra reads, and billing costs. Either store the stream in initState() or use a StatelessWidget (which is rebuilt from its parent and does not hold state between builds). StreamBuilder itself re-subscribes only when the stream object reference changes, so keep it stable.

Using DocumentChanges for Incremental Updates

For large collections where re-rendering the entire list on every snapshot is expensive, iterate over QuerySnapshot.docChanges to process only the items that changed:

  • DocumentChangeType.added — a new document entered the query.
  • DocumentChangeType.modified — an existing document's data changed.
  • DocumentChangeType.removed — a document left the query (deleted or no longer matches filters).

Summary

Firestore streams turn your app into a truly realtime experience. Use snapshots() on document or collection references to subscribe, wire the stream into StreamBuilder for declarative, stateless widgets, or store a StreamSubscription in a StatefulWidget and cancel it in dispose() for imperative control. Always handle all connection states and errors, keep stream references stable across rebuilds, and rely on docChanges for high-performance incremental list updates.