Imagine you’re doing your online shopping on a site like Amazon. You visit a product details page and press the “add to basket” button. The little shopping cart icon in the corner displays a little red bubble with a 1 inside. Now you want to continue shopping and press the back button. Oh no… The red bubble with the one is gone. You quickly refresh the page. And it’s back.
Why am I telling you this story? Well it is because (as the title indicates) push repositories are a great way to tackle issues like that. Let’s first quickly go over how we would fix this without push repositories:
We would have to use the push methods return type to manually refresh the value everywhere it’s necessary like the following. The main issue with this code snippet is that we potentially will end up putting it everywhere.
final shoppingCartCounter = await context.push(…) as int;
updateCounter(shoppingCartCounter);
// Or
final shoppingCartCounter = await context.push(…);
if (shoppingCartCounter is int) {
updateCounter(shoppingCartCounter);
}
Insert our hero: Push Repositories. The idea is that data only ever leaves your repository layer as streams. That way the repository is responsible for keeping its data up to date and simply pushes a new value to the corresponding screen (through a cubit or any other state management solution you prefer) via a stream so you always have all data on every screen 100% up to date.
Let’s take the following repository as a starter and gradually change it to a push repository. For simplicity I am not using rxdart or riverpod (AsyncNotifier) here and we’ll stick to regular stream controllers. We’ll also omit any sort of error handling here as I am sure you can figure that out on your own ;).
class CartRepository {
final Api _api;
ExampleRepository({required Api api}) : _api = api;
Future<int> addToCart(Product product) {
final response = await _api.post(ApiRoutes.product, body: product.toJson());
return response["nrItemsInCart"];
}
Future<int> getNrItemsInCart() {
final response = await _api.get(ApiRoutes.cartNrItems);
return response["nrItemsInCart"];
}
}
Now let’s start by adding our StreamController.
class CartRepository {
...
final StreamController<int> _controller;
ExampleRepository({required Api api}) : _api = api, _controller = StreamController();
...
}
Let’s start off by migrating getNrItemsInCart to our new Push Repository pattern. We first expose a getter to the Stream and give it a descriptive name. Now we have to add values to this stream. We therefore simply add nrItems to our stream via the StreamController. Finally we return our stream here so we don’t have to call loadNrItemsInCart() and then get the stream separately.
class CartRepository {
final Api _api;
final StreamController<int> _controller;
// We'll expose the stream here
Stream<int> get nrItems => _controller.stream;
ExampleRepository({required Api api}) : _api = api, _controller = StreamController();
Future<int> addToCart(Product product) {
final response = await _api.post(ApiRoutes.product, body: product.toJson());
return response["nrItemsInCart"];
}
// Change the return type here to either Stream<int> or void
Future<Stream<int>> loadNrItemsInCart() {
final response = await _api.get(ApiRoutes.cartNrItems);
final nrItems = response["nrItemsInCart"];
// We simply add our new count to the stream
_controller.add(nrItems);
// And return the stream
return nrItems;
}
}
Next we adapt addToCart in a similar fashion:
class CartRepository {
final Api _api;
final StreamController<int> _controller;
Stream<int> get nrItems => _controller.stream;
ExampleRepository({required Api api}) : _api = api, _controller = StreamController();
// Returning void will do here
Future<void> addToCart(Product product) {
final response = await _api.post(ApiRoutes.product, body: product.toJson());
final nrItems = response["nrItemsInCart"];
_controller.add(nrItems);
}
Future<Stream<int>> loadNrItemsInCart() {
final response = await _api.get(ApiRoutes.cartNrItems);
final nrItems = response["nrItemsInCart"];
_controller.add(nrItems);
return nrItems;
}
}
Let’s take a simple cubit that displays our cart value. Again for simplicity no freezed or the likes.
class CartCubit extends Cubit<int> {
final CartRepository _cartRepo;
late final StreamSubscription _sub;
CartCubit({requird CartRepository cartRepo) : _cartRepo = cartRepo, super(0) {
_sub = _cartRepo.nrItems.listen(emit);
}
Future<void> loadNrItems() => await _cartRepo.loadNrItemsInCart();
Future<void> addItem(Product product) => await _cartRepo.addToCart(product);
@override
Future<void> close() async {
await sub?.cancel();
return super.close();
}
}
The close() override can be hidden into a mixin where you simply register the stream subscription and it automatically cancels them for you in the on dispose method.
class CartCubit extends Cubit<int> with SubscriptionAutoDispose{
final CartRepository _cartRepo;
CartCubit({requird CartRepository cartRepo) : _cartRepo = cartRepo, super(0) {
final sub = _cartRepo.nrItems.listen(emit);
autoDispose(sub);
}
Future<void> loadNrItems() => await _cartRepo.loadNrItemsInCart();
Future<void> addItem(Product product) => await _cartRepo.addToCart(product);
}
Now our code here is very concise and the cubit state will always be up to date. The hidden benefit is that if a separate cubit on a previous screen also uses the CartRepository it will automatically update once you call addItem because they share the same stream of the same repository (Repositories should be singletons. Always!). So we now have up to date data everywhere in our app. Isn’t that nice?
I’ll show you a nice way of reloading stale data inside the repository in my next article so consider giving me a follow :D.