flutter
/

BLoC Firebase Integration: Authentication, Firestore & Realtime Updates

Last Sync: Today

On this page

9
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Firebase Integration: Authentication, Firestore & Realtime Updates

What is BLoC Firebase Integration?

BLoC Firebase integration refers to using Firebase services (Authentication, Firestore, Cloud Storage, etc.) within the BLoC pattern for state management. By combining Firebase's powerful backend capabilities with BLoC's predictable state container, you can build reactive, real-time applications with clear separation of concerns. This approach handles authentication flows, real-time data synchronization, offline persistence, and complex error scenarios in a maintainable way.

Why Use Firebase with BLoC?

  • Real-time Updates – Firestore streams map naturally to BLoC state emissions.
  • Authentication – Manage user session, sign-in, sign-out with clear events and states.
  • Offline Support – Firebase handles offline caching; BLoC manages the state while offline.
  • Scalability – BLoC keeps the code organized as Firebase features grow.
  • Testability – Separate Firebase logic into repositories that can be mocked.
  • Seamless User Experience – Combine loading states, error handling, and optimistic updates.

Setting Up Firebase

Add the required dependencies to your pubspec.yaml:

YAMLRead-only
1
dependencies:
  flutter_bloc: ^8.1.5
  equatable: ^2.0.5
  firebase_core: ^2.24.2
  firebase_auth: ^4.17.8
  cloud_firestore: ^4.15.8

# Follow official Firebase setup guide for your platform (iOS, Android, web).

Firebase Authentication BLoC

Create a BLoC to manage authentication state. Use FirebaseAuth to listen to auth state changes and handle sign-in, sign-up, and sign-out.

DARTRead-only
1
// auth_state.dart
enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState extends Equatable {
  final AuthStatus status;
  final User? user;
  final String? error;

  const AuthState({
    this.status = AuthStatus.unknown,
    this.user,
    this.error,
  });

  AuthState copyWith({AuthStatus? status, User? user, String? error}) {
    return AuthState(
      status: status ?? this.status,
      user: user ?? this.user,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [status, user, error];
}

// auth_event.dart
abstract class AuthEvent extends Equatable {
  const AuthEvent();
  @override
  List<Object?> get props => [];
}

class AppStarted extends AuthEvent {}
class SignInRequested extends AuthEvent {
  final String email;
  final String password;
  const SignInRequested(this.email, this.password);
}
class SignUpRequested extends AuthEvent {
  final String email;
  final String password;
  const SignUpRequested(this.email, this.password);
}
class SignOutRequested extends AuthEvent {}
class AuthStateChanged extends AuthEvent {
  final User? user;
  const AuthStateChanged(this.user);
}

// auth_bloc.dart
import 'package:firebase_auth/firebase_auth.dart';

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final FirebaseAuth _auth = FirebaseAuth.instance;

  AuthBloc() : super(const AuthState()) {
    on<AppStarted>(_onAppStarted);
    on<SignInRequested>(_onSignInRequested);
    on<SignUpRequested>(_onSignUpRequested);
    on<SignOutRequested>(_onSignOutRequested);
    on<AuthStateChanged>(_onAuthStateChanged);

    // Listen to Firebase auth state changes
    _auth.authStateChanges().listen((user) {
      add(AuthStateChanged(user));
    });
  }

  void _onAppStarted(AppStarted event, Emitter<AuthState> emit) {
    // Initial state will be set by authStateChanges
  }

  Future<void> _onSignInRequested(SignInRequested event, Emitter<AuthState> emit) async {
    emit(state.copyWith(status: AuthStatus.unknown));
    try {
      await _auth.signInWithEmailAndPassword(
        email: event.email,
        password: event.password,
      );
    } on FirebaseAuthException catch (e) {
      emit(state.copyWith(status: AuthStatus.unauthenticated, error: e.message));
    }
  }

  Future<void> _onSignUpRequested(SignUpRequested event, Emitter<AuthState> emit) async {
    emit(state.copyWith(status: AuthStatus.unknown));
    try {
      await _auth.createUserWithEmailAndPassword(
        email: event.email,
        password: event.password,
      );
    } on FirebaseAuthException catch (e) {
      emit(state.copyWith(status: AuthStatus.unauthenticated, error: e.message));
    }
  }

  Future<void> _onSignOutRequested(SignOutRequested event, Emitter<AuthState> emit) async {
    await _auth.signOut();
  }

  void _onAuthStateChanged(AuthStateChanged event, Emitter<AuthState> emit) {
    if (event.user != null) {
      emit(state.copyWith(status: AuthStatus.authenticated, user: event.user, error: null));
    } else {
      emit(state.copyWith(status: AuthStatus.unauthenticated, user: null, error: null));
    }
  }
}

Firestore with BLoC – Real-time List

For Firestore, you can listen to a collection stream and map the snapshots to BLoC states. Use a repository to abstract Firebase logic.

DARTRead-only
1
// todo_repository.dart
class TodoRepository {
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;

  Stream<List<Todo>> getTodos(String userId) {
    return _firestore
        .collection('users')
        .doc(userId)
        .collection('todos')
        .snapshots()
        .map((snapshot) => snapshot.docs.map((doc) => Todo.fromFirestore(doc)).toList());
  }

  Future<void> addTodo(String userId, Todo todo) async {
    await _firestore
        .collection('users')
        .doc(userId)
        .collection('todos')
        .add(todo.toFirestore());
  }
}

// todo_bloc.dart
class TodoBloc extends Bloc<TodoEvent, TodoState> {
  final TodoRepository repository;
  final String userId;
  StreamSubscription<List<Todo>>? _subscription;

  TodoBloc({required this.repository, required this.userId}) : super(TodoInitial()) {
    on<LoadTodos>((event, emit) async* {
      emit(TodoLoading());
      _subscription = repository.getTodos(userId).listen(
        (todos) => add(TodosUpdated(todos)),
        onError: (error) => add(TodoErrorOccurred(error.toString())),
      );
    });
    on<TodosUpdated>((event, emit) => emit(TodoLoaded(event.todos)));
    on<TodoErrorOccurred>((event, emit) => emit(TodoError(event.message)));
    on<AddTodo>((event, emit) async {
      try {
        await repository.addTodo(userId, event.todo);
      } catch (e) {
        add(TodoErrorOccurred(e.toString()));
      }
    });
  }

  @override
  Future<void> close() {
    _subscription?.cancel();
    return super.close();
  }
}

Handling Offline Persistence

Firestore automatically caches data for offline use. Your BLoC will receive stream events even when offline, using cached data. You can check connectivity and show appropriate UI states.

DARTRead-only
1
// Enable offline persistence (optional, enabled by default)
await FirebaseFirestore.instance.enablePersistence();

// In BLoC, you can check if the snapshot is from cache
_snapshot.metadata.isFromCache // to show stale indicator

Best Practices

  • Use a Repository Pattern – Abstract Firebase away from BLoCs for testability.
  • Close Stream Subscriptions – Always cancel streams in close() to avoid memory leaks.
  • Handle Errors Gracefully – Catch Firebase exceptions and emit error states.
  • Leverage Firebase Auth State Stream – Listen to auth changes and update BLoC accordingly.
  • Use Equatable for States – Prevents unnecessary rebuilds when state values haven't changed.
  • Optimize Firestore Queries – Use limit, orderBy, and where clauses to reduce data transfer.
  • Consider Offline UI – Show loading indicators and offline banners when network is lost.

Common Mistakes

  • ❌ Not cancelling Firestore subscriptions – Leads to memory leaks and unwanted state updates.
  • ❌ Storing large documents in state – Keep state lightweight; Firestore documents can be large.
  • ❌ Calling emit after bloc is closed – Use isClosed check if necessary.
  • ❌ Not handling FirebaseAuthException – Generic catches hide important error details.
  • ❌ Exposing Firebase logic in UI – Keep Firebase code in repositories or BLoCs.
  • ❌ Forgetting to initialize Firebase – Call Firebase.initializeApp() before runApp.

Conclusion

Integrating Firebase with BLoC gives you a robust architecture for real-time, offline-capable apps. By separating concerns into repositories and BLoCs, you can test business logic independently and build scalable features. With proper error handling and stream management, you'll create a seamless user experience.

Try it yourself

// This is a demo of BLoC with Firebase concepts, using a mock repository to simulate Firebase.
// In a real app, you'd replace the mock with actual Firebase services.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// ---------- MOCK FIREBASE ----------
class MockFirebaseAuth {
  static User? currentUser;

  Future<User> signInWithEmailAndPassword(String email, String password) async {
    await Future.delayed(Duration(seconds: 1));
    if (email == 'test@example.com' && password == 'password') {
      currentUser = User(email: email);
      return currentUser!;
    } else {
      throw Exception('Invalid credentials');
    }
  }

  Future<User> createUserWithEmailAndPassword(String email, String password) async {
    await Future.delayed(Duration(seconds: 1));
    currentUser = User(email: email);
    return currentUser!;
  }

  Future<void> signOut() async {
    await Future.delayed(Duration(milliseconds: 500));
    currentUser = null;
  }

  Stream<User?> authStateChanges() async* {
    yield currentUser;
  }
}

class User {
  final String email;
  User({required this.email});
  @override
  bool operator ==(Object other) => other is User && other.email == email;
  @override
  int get hashCode => email.hashCode;
}

// ---------- Auth BLoC ----------
enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthState extends Equatable {
  final AuthStatus status;
  final User? user;
  final String? error;

  const AuthState({this.status = AuthStatus.unknown, this.user, this.error});

  AuthState copyWith({AuthStatus? status, User? user, String? error}) {
    return AuthState(
      status: status ?? this.status,
      user: user ?? this.user,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [status, user, error];
}

abstract class AuthEvent extends Equatable {
  const AuthEvent();
  @override
  List<Object?> get props => [];
}

class AppStarted extends AuthEvent {}
class SignInRequested extends AuthEvent {
  final String email;
  final String password;
  const SignInRequested(this.email, this.password);
}
class SignUpRequested extends AuthEvent {
  final String email;
  final String password;
  const SignUpRequested(this.email, this.password);
}
class SignOutRequested extends AuthEvent {}
class AuthStateChanged extends AuthEvent {
  final User? user;
  const AuthStateChanged(this.user);
}

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final MockFirebaseAuth _auth = MockFirebaseAuth();

  AuthBloc() : super(const AuthState()) {
    on<AppStarted>(_onAppStarted);
    on<SignInRequested>(_onSignInRequested);
    on<SignUpRequested>(_onSignUpRequested);
    on<SignOutRequested>(_onSignOutRequested);
    on<AuthStateChanged>(_onAuthStateChanged);

    _auth.authStateChanges().listen((user) {
      add(AuthStateChanged(user));
    });
  }

  void _onAppStarted(AppStarted event, Emitter<AuthState> emit) {}

  Future<void> _onSignInRequested(SignInRequested event, Emitter<AuthState> emit) async {
    emit(state.copyWith(status: AuthStatus.unknown));
    try {
      await _auth.signInWithEmailAndPassword(event.email, event.password);
    } catch (e) {
      emit(state.copyWith(status: AuthStatus.unauthenticated, error: e.toString()));
    }
  }

  Future<void> _onSignUpRequested(SignUpRequested event, Emitter<AuthState> emit) async {
    emit(state.copyWith(status: AuthStatus.unknown));
    try {
      await _auth.createUserWithEmailAndPassword(event.email, event.password);
    } catch (e) {
      emit(state.copyWith(status: AuthStatus.unauthenticated, error: e.toString()));
    }
  }

  Future<void> _onSignOutRequested(SignOutRequested event, Emitter<AuthState> emit) async {
    await _auth.signOut();
  }

  void _onAuthStateChanged(AuthStateChanged event, Emitter<AuthState> emit) {
    if (event.user != null) {
      emit(state.copyWith(status: AuthStatus.authenticated, user: event.user, error: null));
    } else {
      emit(state.copyWith(status: AuthStatus.unauthenticated, user: null, error: null));
    }
  }
}

// ---------- UI ----------
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => AuthBloc()..add(AppStarted()),
        child: AuthWrapper(),
      ),
    );
  }
}

class AuthWrapper extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<AuthBloc, AuthState>(
      builder: (context, state) {
        if (state.status == AuthStatus.authenticated) {
          return HomePage(user: state.user!);
        } else if (state.status == AuthStatus.unauthenticated) {
          return LoginPage();
        } else {
          return Scaffold(body: Center(child: CircularProgressIndicator()));
        }
      },
    );
  }
}

class LoginPage extends StatefulWidget {
  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLogin = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Firebase Auth Demo')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _emailController,
              decoration: InputDecoration(labelText: 'Email'),
            ),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            ElevatedButton(
              onPressed: () {
                if (_isLogin) {
                  context.read<AuthBloc>().add(SignInRequested(
                    _emailController.text,
                    _passwordController.text,
                  ));
                } else {
                  context.read<AuthBloc>().add(SignUpRequested(
                    _emailController.text,
                    _passwordController.text,
                  ));
                }
              },
              child: Text(_isLogin ? 'Login' : 'Sign Up'),
            ),
            TextButton(
              onPressed: () => setState(() => _isLogin = !_isLogin),
              child: Text(_isLogin ? 'Create account' : 'Already have an account?'),
            ),
            if (context.watch<AuthBloc>().state.error != null)
              Text(
                'Error: ${context.watch<AuthBloc>().state.error}',
                style: TextStyle(color: Colors.red),
              ),
            SizedBox(height: 20),
            Text('Demo credentials: test@example.com / password', style: TextStyle(fontSize: 12)),
          ],
        ),
      ),
    );
  }
}

class HomePage extends StatelessWidget {
  final User user;
  const HomePage({required this.user});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Welcome ${user.email}'),
        actions: [
          IconButton(
            icon: Icon(Icons.logout),
            onPressed: () => context.read<AuthBloc>().add(SignOutRequested()),
          ),
        ],
      ),
      body: Center(
        child: Text('This is your dashboard.\nFirestore integration would go here.',
          textAlign: TextAlign.center),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 4

What method of FirebaseAuth is used to listen to authentication state changes?

A
onAuthStateChanged
B
authStateChanges()
C
userChanges()
D
idTokenChanges()
Q2
of 4

How should you manage Firestore stream subscriptions in a BLoC to avoid memory leaks?

A
Use a global variable
B
Cancel them in the close() method
C
Let them run forever
D
Use a StreamBuilder
Q3
of 4

What pattern is recommended to abstract Firebase logic from BLoCs?

A
Repository pattern
B
Singleton pattern
C
Factory pattern
D
Observer pattern
Q4
of 4

How does Firestore handle offline persistence?

A
It requires manual setup
B
It caches data automatically
C
It does not support offline
D
Only works on Android

Frequently Asked Questions

How do I test a BLoC that uses Firebase?

Mock the Firebase services using mocktail. Create a fake repository that returns controlled data or errors. Then test your BLoC using blocTest with these mocks.

Can I use BLoC with Firebase Realtime Database?

Yes, the approach is similar: use a repository that exposes a stream of data from the database, and let your BLoC listen to it.

How to handle authentication state persistence with BLoC?

Firebase Auth automatically persists the user session. Your AuthBloc listens to authStateChanges() and updates the state accordingly. No extra persistence needed.

What about pagination with Firestore?

Use query cursors and combine them with a BLoC that holds the last document. Emit new states as pages are loaded. Be careful to manage the stream properly.

How to handle Firestore security rules in development?

For testing, you can use the Firebase Emulator Suite. Your BLoC code remains unchanged; you just point to the emulator during development.

Previous

bloc flutter web

Next

bloc vs getx

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.