Heart for people
Mind for tech

BloC vs. Riverpod in Flutter

De eerlijke mening van een Flutter Developer

Elitsa Marinova

Engineering

Traffic lights

Written by

Elitsa Marinova
Software Developer

Dit artikel is oorspronkelijk geschreven in het Engels.

Ik begon als stagiair bij Baseflow op een Flutter project (deze komt binnenkort op onze Cases pagina). Een van de eerste uitdagingen was state management. Al snel bleek: bij Baseflow is de standaard BloC.

Dus begon mijn BloC-avontuur. Een nieuwe ervaring, met een structuur die even wennen was. Maar naarmate de tijd verstreek, leerde ik de principes, gebruikte ik het op project na project, en stelde ik het nooit ter discussie. Het was duidelijk, goed gestructureerd, en ik kon ermee doen wat ik wilde. Klinkt als de perfecte oplossing, toch?

Maar bij Baseflow geven we teams zoveel mogelijk vrijheid, ook in de keuze voor een tech stack. Toen ik de opdracht kreeg om mijn Flutter-kennis te verdiepen, met veel ruimte voor eigen invulling, begon ik te onderzoeken. Op een gegeven moment stuitte ik opnieuw op state management en herinnerde ik me: BloC is lang niet de enige optie. Riverpod is ook populair.

Ik nam de vrijheid om verder te kijken. Want waarom zou je bij één oplossing blijven als je niet weet wat de rest te bieden heeft? Ik begon Riverpod te lezen. Het aanpakte problemen anders dan BloC, en dat trok mijn aandacht. Ik besloot er dieper in te duiken. Twee mogelijke uitkomsten: ik word er verliefd op en probeer mijn collega's te overtuigen om over te stappen, of ik houd het bij BloC, maar dan met de zekerheid dat ik dat bewust kies.

Opzet

Eerst moest ik bepalen hoe ik de twee zou vergelijken. Ik koos voor een vergelijking op basis van code, zij aan zij. Ik startte een nieuw Flutter-project, met Clean Architecture als basis. Een gedeelde data- en core-laag zodat de data altijd gelijk blijft. Alleen de business logic verschilt: één keer met BloC, één keer met Riverpod.

Het project: een Trello-achtig bord. Kolommen met taken, een reload-knop, twee aparte laadweergaven (met en zonder data), een knop om kolommen toe te voegen, enzovoort. Precies genoeg om de verschillende states goed te kunnen verkennen. Mijn voornaamste vragen: hoe toon je een laadstatus terwijl er al data zichtbaar is? Hoe verwerk je een lege of foutweergave? En hoe verschilt dat tussen BloC en Riverpod?

De reis

Ik begon met de UI, simpel en klaar om aan elk state management-systeem te koppelen. Daarna de core- en data-laag. Klaar voor gebruik door beide oplossingen.

BloC

Ik begon met BloC, want dat ken ik al. Het plan: vooraf alle states bepalen, de nodige boilerplate schrijven voor cubits en states, en die koppelen aan de UI en de bestaande data-laag.

Models

Mijn structuur was eenvoudig. Hier een voorbeeld van mijn board entity. Ik gaf het een id en een lijst met kolommen, en ik liet de klasse Equatable extenden zodat de state elke wijziging kan bijhouden.

@immutable
class BoardEntity extends Equatable {
  BoardEntity({required this.id, required List<ColumnEntity> columnsList})
    : columnsList = List<ColumnEntity>.unmodifiable(columnsList);

  final int id;
  final List<ColumnEntity> columnsList;
  
  // ...
}

States en de UI

In BloC gebruik je een BlocProvider om de cubit beschikbaar te maken in de widget tree, en een BloC Widget om de UI aan te sturen. Meestal kies je uit vier opties: BlocBuilder, BlocSelector, BlocListener of BlocConsumer. Voor de BoardPage koos ik voor een BlocBuilder:

body: BlocBuilder<BoardPageCubit, BoardPageState>(
  builder: (context, state) {
    if (state is BoardPageLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (state is BoardPageEmpty) {
      return Padding(padding: .all(16.0), child: BoardColumn.empty());
    }
    if (state is BoardPageError) {
      return BoardViewError();
    }
    if (state is HasDataState) {
      return BoardView(board: state.boardEntity, isOverlayLoading: state.isLoading);
    } else {
      return const SizedBox.shrink();
    }
  },
),

Een voorbeeld van een cubit-methode, samen met de bijbehorende state class:

Future<void> addCard() async {
  emit(state.copyWith(loading: true));
  final updatedBoardColumn = await _service.addCardToColumn(
    columnId: state.boardColumnEntity.id,
  );
  emit(
    state.copyWith(boardColumnEntity: updatedBoardColumn, loading: false),
  );
}
class BoardColumnState extends Equatable {
  const BoardColumnState({
    required this.columnEntity,
    this.loading = false,
    this.errorMessage,
  });

  final ColumnEntity columnEntity;
  final bool? loading; 
  final String? errorMessage;

  @override
  List<Object?> get props => [columnEntity, loading, errorMessage];

  BoardColumnState copyWith({
    ColumnEntity? columnEntity,
    bool? loading,
    String? errorMessage,
  }) {
    return BoardColumnState(
      columnEntity: columnEntity ?? this.columnEntity,
      loading: loading ?? this.loading,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }
}

Riverpod

Met de BloC-implementatie op orde was het tijd voor Riverpod. Een traject met ups en downs. Ik begon met een grondige leessessie in de documentatie om überhaupt te begrijpen hoe ik moest starten. Op dit punt is het goed om even stil te staan bij een fundamenteel verschil: BloC is een state management-oplossing, terwijl Riverpod een data management-oplossing is die je UI-states ook goed laat afhandelen. Wat betekent dat in de praktijk? Dat ontdekte ik al snel.

Providers

Ik begon met een boardProvider.

final boardProvider =
    AsyncNotifierProvider.autoDispose<BoardNotifier, BoardEntity>(
      BoardNotifier.new,
    );

En al snel bleek dat ik ook een BoardNotifier nodig had:

class BoardNotifier extends AsyncNotifier<BoardEntity> {
  @override
  Future<BoardEntity> build() async {
    final service = ref.watch(boardServiceProvider);
    return service.fetchBoard();
  }

  Future<void> addColumn() async {
    // ...

UI

Daarna kon ik aan de slag met de states in de UI. Je hoeft je widget alleen maar te extenden met ConsumerWidget om toegang te krijgen tot de WidgetRef. Ben je niet bekend met Riverpod-terminologie? Bekijk dan de documentatie. En toen... die eerste straal zon na een lange Nederlandse winter.

body: boardRef.when(
  data: (value) => BoardView(board: value),
  loading: () => const Center(child: CircularProgressIndicator()),
  error: (e, _) => const Text("Error"),
),

Wil je zeggen dat ik geen aparte code hoef te schrijven voor verschillende states? Dat Riverpod dat gewoon voor je regelt? Vijf regels code tegenover de 10+ van BloC, en dat nog voordat je kijkt naar alle state- en cubit-klassen die je bij BloC moet schrijven. Riverpod scoorde hier flink.

Die blijdschap was van korte duur.

De uitdaging

Ik had het project bewust zo opgezet dat er uitdagingen zouden komen, en die dienden zich snel aan. Vraagje: hoe toon je een laadstatus terwijl er al data zichtbaar is?

In BloC doe je dat zo:

if (state is HasDataState) {
  return BoardView(
    board: state.boardEntity,
    isOverlayLoading: state.isLoading,
  );
} 

De state houdt die informatie gewoon bij. Maar in Riverpod geldt: óf je ontvangt data, óf je bent aan het laden. Niet allebei tegelijk. Ik dook er dieper in en vond een oplossing: een extra provider die bijhoudt of de "laden met data"-status actief is via een boolean.

Daarna wilde ik specifieke functionaliteit toevoegen voor een kolom, en dat leverde twee opties op. Optie één: blijf de board provider gebruiken. Je hebt toegang tot vrijwel alle data, maar je moet alsnog de columnId meegeven als je een kolom wilt verwijderen.

Future<void> deleteColumn(String columnId) async {
  ref.read(isOverlayLoadingProvider.notifier).state = true;
  final service = ref.read(boardServiceProvider);
  final result = await service.deleteColumn(columnId: columnId);
  state = AsyncData(result);
  ref.read(isOverlayLoadingProvider.notifier).state = false;
}

Je kunt de state dus niet gebruiken om het ID op te halen, zoals je dat in BloC wel kunt. In BloC had ik een ColumnState, en elke state had een eigen ID. Ik verwachtte dat Riverpod iets vergelijkbaars zou bieden, maar dat is geen gangbare aanpak.

Toch nam ik de uitdaging aan en maakte een columnProvider, en dat werkte redelijk goed:

final columnProvider = AsyncNotifierProvider.autoDispose
    .family<ColumnNotifier, ColumnEntity, String>(
      (String id) => ColumnNotifier(id),
    );

Met deze column provider kan ik per kolom werken en de bijbehorende states bijhouden. Het lijkt de logische aanpak, maar ik denk niet dat er één juist antwoord is bij Riverpod. Welke keuze je maakt, hangt sterk af van de data waarmee je werkt.

Stel: je haalt een board op met alleen kolom-id's, geen kolom-data zelf, en je hebt een methode om een kolom op te halen. Dan kies ik waarschijnlijk voor een columnProvider. Riverpod is een data-binding-oplossing, en dit sluit daar goed op aan.

De andere optie: één boardProvider die alle data beheert én alle acties afhandelt. Wat omslachtig, maar het werkt prima.

Conclusie

Riverpod biedt veel. Het is veelzijdig, de leercurve valt mee, en als je snel een project wilt opzetten, is het een goede keuze. Wat ik miste: de documentatie geeft weinig houvast bij complexere scenario's. En omdat Riverpod zo nauw verbonden is met data, voelde het soms beperkend als je iets buiten die structuur wilde doen.

Voor nu blijf ik bij BloC (we bhandelen BLoC ook in onze Flutter training trouwens). Niet omdat het de perfecte, alles-omvattende oplossing is, want BloC heeft ook nadelen. Er is best wat boilerplate, en de leercurve is steiler. Maar waar BloC voor mij wint: de flexibiliteit. Je kunt je data precies zo bewerken als je nodig hebt, en dat vrij eenvoudig ook. Geschikt voor complexe projecten, goed schaalbaar.

Wat jij kiest voor je eigen Flutter-app hangt af van de complexiteit, schaalbaarheidsbehoeften en deadlines van je project. En van wat jij goed kent.