Bits of Flutter
Architecture · · 8 min read

Repository Stream Pattern: a Reactive Way to Keep Your UI in Sync ⚡️

This one has been my life saver for a long time already. It’s very common when building Flutter apps to face the scenario where you have to decide which strategy to use to update and read information that is shared across different screens. And not just how to do it, but how to make it scalable and efficient.

I’m sure you’re already thinking about a shared service or repository, and you would be right. But here’s something that makes it even more efficient: using streams as a contract for the information consumed by the UI.

Let’s imagine we have an app where we can see a list of cars and add new cars to that list. This information can be seen from the home screen, but in the future it might be needed in other screens too.

We also can add new cars through a form that can be on a separate page or on the home page itself.

For this simple scenario, just putting all the logic for adding and reading cars in the same controller, bloc, or notifier will be enough. But as the app grows and more screens or UI components need updated information about the cars, you’ll find yourself repeating logic, prop-drilling data, or even worse: making blocs depend on other blocs (if you’re using BLoC at all). See When Blocs Start Talking Too Much.

Another way of handling this, in case the form is on a different screen (which is the most common scenario) is to open the form page and await the navigation pop to get the updated car information or refresh the home page.

It works, but the home page will no longer be reactive, other screens updating the cars will not refresh the home page, and you will find yourself repeating logic in multiple places.

The Repository Stream Pattern

Instead of the presentation layer (screens, blocs, view controllers, etc) keeping track on the latest data, the repository becomes the single source of truth.

So how do we communicate this truth to the presentation layer? by exposing a stream as the contract between the UI and the Repository.

The repository stores the current list of cars via a stream controller and has methods like fetchCars or addCar that, when finished doing their stuff, update the stream with the data.

With this in place, the UI only needs to call the methods and listen to the stream of the repository so it will be able to refresh every time the stream emits new data.

Benefits of the Repository Stream Pattern

  • Scalability: If you add screens like favorites, filters, search results, or car details, you already have one reactive stream with the information you need.
  • Avoids tight coupling: Screens do not talk to each other, and blocs do not depend on each other.
  • Reactive by default: You do not decide when to reload, you just plug into the repository stream and rebuild the UI when the information changes.

Data and Domain Layer Example

class Car {
  final String id;
  final String brand;
  final String model;

  const Car({required this.id, required this.brand, required this.model});
}

abstract class CarRepository {
  Stream<List<Car>> get carsStream;
  Future<void> fetchCars();
  Future<void> addCar(Car car);
}

class CarRepositoryImpl implements CarRepository {
  final _carsController = BehaviorSubject<List<Car>>.seeded([]);
  final CarApiClient _apiClient;

  CarRepositoryImpl(this._apiClient);

  @override
  Stream<List<Car>> get carsStream => _carsController.stream;

  @override
  Future<void> fetchCars() async {
    final cars = await _apiClient.getCars();
    _carsController.add(cars);
  }

  @override
  Future<void> addCar(Car car) async {
    await _apiClient.createCar(car);
    final updatedCars = [..._carsController.value, car];
    _carsController.add(updatedCars);
  }
}

Presentation Layer Example (with BLoC)

// Events
abstract class HomeEvent {}
class HomeStarted extends HomeEvent {}
class HomeCarAdded extends HomeEvent {
  final Car car;
  HomeCarAdded(this.car);
}

// State
class HomeState {
  final List<Car> cars;
  final bool isLoading;
  final String? error;

  const HomeState({
    this.cars = const [],
    this.isLoading = false,
    this.error,
  });

  HomeState copyWith({
    List<Car>? cars,
    bool? isLoading,
    String? error,
  }) {
    return HomeState(
      cars: cars ?? this.cars,
      isLoading: isLoading ?? this.isLoading,
      error: error,
    );
  }
}

// BLoC
class HomeBloc extends Bloc<HomeEvent, HomeState> {
  final CarRepository _carRepository;

  HomeBloc(this._carRepository) : super(const HomeState()) {
    on<HomeStarted>(_onStarted);
    on<HomeCarAdded>(_onCarAdded);
  }

  Future<void> _onStarted(
    HomeStarted event,
    Emitter<HomeState> emit,
  ) async {
    emit(state.copyWith(isLoading: true));

    // Listen to the repository stream
    await emit.forEach<List<Car>>(
      _carRepository.carsStream,
      onData: (cars) => state.copyWith(cars: cars, isLoading: false),
      onError: (error, _) => state.copyWith(
        error: error.toString(),
        isLoading: false,
      ),
    );
  }

  Future<void> _onCarAdded(
    HomeCarAdded event,
    Emitter<HomeState> emit,
  ) async {
    await _carRepository.addCar(event.car);
    // No need to emit here — the stream will update the UI!
  }
}

Final Thoughts

This pattern is simply a strategy that works really well for me, but you probably already have your own approach and that’s totally fine if it works for you!

In the end, this is just about taking the domain layer and making it expose a stream so the UI can react to its changes.

It solved a lot of problems for me, so I hope it’s useful for you too.

Until the next bit!

Related Posts

BLoC Powered Components

Architecture

Don't make one single BLoC for the whole page. Follow the Single Responsibility Principle and split UI components into small features with their own BLoCs.

Read More

When BLoCs Start Talking Too Much 🧩

Architecture

(and how the Mediator Pattern can save you) Sometimes in a Flutter app, one BLoC starts depending on another. Maybe the profile screen needs the current user ID from the AuthBloc. So we inject one BLoC into the other. It works. Until it doesn't.

Read More