Riverpod vs. Bloc: Damit ihr es in Produktion nicht bereut – ein praxisnaher Entscheidungsleitfaden nach Teamgröße

Wenn es um die Wahl einer State-Management-Bibliothek für Flutter geht, stößt man meist auf Feature-Vergleichstabellen nach dem Muster: „Riverpod punktet mit Compile-Time-Sicherheit, Bloc ist durch seinen Event-getriebenen Aufbau leicht testbar.“ Das ist nicht falsch, aber was Teams in der Produktion tatsächlich zu schaffen macht, sind nicht Feature-Listen, sondern Betriebskosten, die sich erst nach sechs Monaten bis einem Jahr zeigen. Dieser Artikel entscheidet nicht, welche der beiden Bibliotheken überlegen ist. Stattdessen zeigt er zunächst, welche Probleme jede Bibliothek in der Praxis tatsächlich verursacht, und liefert anschließend Kriterien dafür, wann man sich für welche Lösung entscheidet – und wann sich eine Migration je nach Teamgröße und Domäne überhaupt lohnt.
Das Wichtigste in Kürze
- Das eigentliche Risiko von Riverpod ist nicht ein Mangel an Features, sondern Stale State durch Fehler im Provider-Scope. Vor allem Pfade wie
showDialog,showModalBottomSheetoderrootNavigator: trueverlassen denProviderScope-Override-Baum und lesen dadurch eine andere Instanz als erwartet.- Riverpod-Codegen (
@riverpod+ build_runner) erzeugt Reibung in der Zusammenarbeit..g.dart-Dateien erhöhen das Diff-Rauschen, und neue Teammitglieder starten häufig mit einem rot markierten Build.- Das eigentliche Risiko von Bloc ist die Vermehrung von Event-Klassen (Event Explosion). Wenn sich Events wie eine eigene Domänensprache anhäufen, verwenden neue Entwickler bestehende Events an Stellen mit anderer Bedeutung wieder – mit verworrenen Seiteneffekten als Folge.
- Bei einem MVP mit 1–3 Personen im Team ist die Reibung auf Riverpod-Seite gering; bei großen Organisationen, in denen mehrere Squads Zustand wie über einen Vertrag austauschen müssen, oder in Finanz-/Commerce-Domänen mit Event-Sourcing-Bedarf spielt die explizite Struktur von Bloc ihre Stärken aus.
- Stand Juli 2026 sind die stabilen Versionen
riverpod3.3.2,riverpod_generator4.0.4,flutter_bloc9.1.1,bloc_test10.0.0 undprovider6.1.5+1 (Stand pub.dev).
Warum die Feature-Vergleichstabelle die falsche Frage stellt
Sowohl Riverpod als auch Bloc schneiden bei Kriterien wie „testbar“, „unterstützt DI“ und „Compile-Time-Sicherheit“ gut ab. Beide funktionieren in der Produktion tatsächlich zuverlässig. Das Problem ist, dass diese Vergleichstabelle nur die Lernkurve beim Einstieg zeigt, aber nichts darüber aussagt, was Reibung erzeugt, sobald das Team wächst und die Codebasis sich vergrößert. Schaut man sich Issue-Tracker und Community-Diskussionen an, lassen sich die wiederkehrenden Schmerzpunkte auf genau zwei Achsen reduzieren: Bei Riverpod ist es das Problem, dass „ein falsch gehandhabter Scope den Zustand durcheinanderbringt“, bei Bloc das Problem, dass „mit wachsender Zahl an Events das Team die Bedeutung der Events nicht mehr unter Kontrolle hat“.
Wo es bei Riverpod in der Produktion tatsächlich knallt
Stale State durch Fehler im Provider-Scope
Das Scope-Modell von Riverpod ist mächtig, hat aber Stellen, an denen es der Intuition widerspricht. Schauen wir uns zunächst ein typisches Fehlermuster an.
// 주문 상세 화면에서만 유효한 orderId를 스코프에 주입
class OrderDetailPage extends ConsumerWidget {
const OrderDetailPage({required this.orderId, super.key});
final String orderId;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ProviderScope(
overrides: [
currentOrderIdProvider.overrideWithValue(orderId),
],
child: const _OrderDetailBody(),
);
}
}
Bis hierhin ist alles normal. Das Problem entsteht, wenn innerhalb von _OrderDetailBody showDialog(context: context, ...) oder showModalBottomSheet aufgerufen wird. Diese Widgets hängen sich in den meisten Fällen an das Overlay des Root-Navigators. Sie werden also außerhalb des ProviderScope-Teilbaums gerendert – ruft man innerhalb des Dialogs ref.watch(currentOrderIdProvider) auf, liest man den globalen Default-Wert (oder einen Rest-Wert vom vorherigen Screen), weil der Override dort nicht greift. So entsteht ein äußerst schwer reproduzierbarer Bug, bei dem der Screen Bestellung A anzeigt, der Dialog aber Daten von Bestellung B zeigt.
Die zweite häufige Ursache ist der falsche Einsatz von autoDispose. Wer, wie es viele Tutorials vormachen, gewohnheitsmäßig jedem Provider .autoDispose verpasst, sorgt dafür, dass der Provider in dem Moment verworfen wird, in dem beim Screen-Wechsel der letzte Listener verschwindet. Hängt man autoDispose sogar an Zustand, der über mehrere Screens hinweg erhalten bleiben muss – etwa eine Nutzersession –, wirkt es beim Zurücknavigieren so, als wären Login-Status oder Warenkorb-Inhalt zurückgesetzt worden. Tatsächlich handelt es sich nicht um einen „veralteten Wert“, sondern um einen „neu erzeugten Initialwert“ – aus Nutzersicht fühlt sich aber beides gleich nach Stale State an.
Die Gegenmaßnahmen sind klar.
.autoDisposenur an rein bildschirmlokalen, temporären Zustand hängen; bei Session-/App-weitem Zustand explizitref.keepAlive()aufrufen oder von vornherein kein autoDispose verwenden.- Bei Dialogen, Bottom Sheets oder Widgets mit eigenem Navigator die benötigten Werte bereits beim Build des
ConsumerWidgetlesen und als Parameter übergeben – oder den Override im Root-ProviderScopesetzen, sodass unabhängig vom gelesenen Teilbaum immer dieselbe Instanz zurückkommt. - In die Code-Review-Checkliste explizit die Frage aufnehmen: „Welchen Teilbaum umschließt dieser Override tatsächlich?“
Reibung in der Zusammenarbeit und Diff-Rauschen durch build_runner
Setzt man riverpod_generator ein, braucht jede mit @riverpod annotierte Klasse bzw. Funktion ein part 'x.g.dart';, und damit die IDE beim Speichern keine roten Fehlermeldungen zeigt, muss im Hintergrund dauerhaft dart run build_runner watch --delete-conflicting-outputs laufen. Auf Teamebene erzeugt das drei Arten von Reibung.
- Onboarding-Reibung: Klont ein neues Teammitglied das Repo und führt nur
flutter pub getaus, ist das Projekt voller roter Fehler. Derbuild_runner-Lauf fehlt leicht in der Onboarding-Doku, und fehlt er, taucht immer wieder dieselbe Frage auf: „Warum kompiliert das nicht, obwohl ich noch gar keinen Code geändert habe?“ - Diff-Rauschen: Committet ein Team
.g.dart-Dateien ins Repository, ändern sich schon bei einer einzigen geänderten Provider-Signatur Dutzende bis Hunderte Zeilen generierten Codes mit. Reviewer müssen dann kognitiven Aufwand betreiben, um echte Logikänderungen von generierten Änderungen zu unterscheiden. - Längere CI-Zeiten: Committet ein Team
.g.dartdagegen nicht, muss jede CI-Pipeline die Codegenerierung erneut ausführen – und je größer das Monorepo wird, desto spürbarer wächst die inkrementelle Build-Zeit.
Es gibt Abhilfe. Riverpod lässt sich auch ganz ohne Codegen, allein mit der klassischen Provider-/NotifierProvider-/StateNotifierProvider-Syntax, vollwertig einsetzen. Ist ein Team noch nicht so weit, dass sich der Komfort von Codegen (automatische .family-/.autoDispose-Inferenz, weniger Boilerplate) wirklich auszahlt, ist es der realistischere Weg, anfangs im Non-Codegen-Stil zu starten und erst dann umzusteigen, wenn sich Konventionen etabliert haben – das reduziert die Reibung in der Zusammenarbeit.
Die typische Falle von Bloc: Event-Explosion (Vermehrung von Event-Klassen)
Bloc erzwingt einen expliziten Ablauf nach dem Muster „Event → Bloc → State“. Diese Explizitheit ist am Anfang ein Vorteil, doch je mehr Features hinzukommen, desto stärker wächst die Zahl der Event-Klassen – geradezu exponentiell.
abstract class CartEvent extends Equatable {
const CartEvent();
@override
List<Object?> get props => [];
}
class CartItemAdded extends CartEvent { /* ... */ }
class CartItemAddedFromRecommendation extends CartEvent { /* ... */ } // 분석 필드만 다름
class CartItemQuantityIncreased extends CartEvent { /* ... */ }
class CartItemQuantityDecreased extends CartEvent { /* ... */ }
class CartItemQuantityChangedManually extends CartEvent { /* ... */ } // 디바운스 타이머 트리거
class CartCouponApplied extends CartEvent { /* ... */ }
class CartCouponAppliedFromDeepLink extends CartEvent { /* ... */ } // 부수효과가 다름
Was hier in der Praxis tatsächlich passiert, sieht so aus: Ein neuer Entwickler fügt das Feature „Menge aller Warenkorbartikel auf einmal ändern“ hinzu und verwendet dafür den bestehenden CartItemQuantityChangedManually-Handler wieder. Vom Namen her wirkt das unproblematisch – schließlich ist es „ein Event, das die Menge ändert“ –, doch im Inneren des Handlers hingen tatsächlich ein Debounce-Timer und Analytics-Logging, die nur bei manueller Eingabe nötig waren. Das Ergebnis: Bei der Sammeländerung entstehen so viele Debounce-Timer gleichzeitig wie es Artikel gibt, und der Analytics-Server zeichnet N-mal ein „manuelles Ändern“-Event auf, das der Nutzer nie ausgelöst hat. Der Event-Name klang wie Domänensprache, verbarg in Wirklichkeit aber einen Seiteneffekt, der nur zufällig an den Handler gekoppelt war.
Dieses Problem entsteht nicht durch mangelnde Codequalität, sondern weil Event-Naming und Handler-Verantwortlichkeit nicht sauber getrennt sind. Folgende Gegenmaßnahmen kann ein Team konkret ergreifen.
- Event-Klassen sollten ausschließlich abbilden, was passiert ist – Seiteneffekte wie Debouncing oder Analytics-Logging gehören in eine separate, middleware-artige Schicht (z. B. die Event-Transformer von
bloc_concurrencyoder die Repository-Ebene). - Wenn ein Feature schlicht „einen einzelnen Wert aktualisiert“ und die Event-Historie selbst keinen Domänenwert besitzt, sollte man von vornherein statt
Blocdas ereignisklassenloseCubitverwenden. Für Screen-Zustand, der weder Audit-Log noch Replay benötigt, muss man keine Event-Schicht erzwingen. - Im PR-Template eine Checkbox einbauen, die fragt: „Warum reicht die Wiederverwendung dieses Events nicht aus – warum braucht es ein neues?“ So fällt Event-Wiederverwendung Reviewern garantiert auf.
- Die Transformer
sequential(),droppable()undrestartable()aus dem Paketbloc_concurrencykonsequent nutzen, um festzulegen, was passiert, wenn dasselbe Event mehrfach schnell hintereinander eintrifft – und zwar über die Transformer-Konfiguration, nicht über die Event-Definition.
Entscheidungstabelle nach Teamgröße und Domäne
| Team/Domäne | Empfehlung | Begründung |
|---|---|---|
| Startup-MVP mit 1–3 Personen | Riverpod (non-codegen) | Schnelles Pivotieren ohne das Boilerplate, für jedes Feature ein Event/State/Bloc-Trio anzulegen. Codegen lässt sich später noch problemlos nachrüsten. |
| Wachsendes Team mit 4–15 Personen | Hängt von der Kapazität für Konventions-Dokumentation ab | Der explizite Vertrag von Bloc hilft beim Onboarding, setzt aber voraus, dass Event-Governance (Regeln gegen Wiederverwendung) auch dokumentiert und in Reviews durchgesetzt werden kann. Fehlt diese Kapazität, ist Riverpod + Notifier-Pattern die pflegeleichtere Wahl. |
| Großes Enterprise (mehrere Squads) | Bloc | Event-/State-Klassen fungieren wie ein Vertrag zwischen Squads, und Interface-Änderungen werden im PR-Review klar sichtbar. Der implizite Abhängigkeitsgraph von Riverpod verbirgt Kopplungen zwischen Squads leicht, wenn er nicht per Lint erzwungen wird. |
| Finanz-/Commerce-Domäne mit Event-Sourcing-Bedarf | Bloc | Domänen-Events entsprechen fast eins zu eins den Bloc-Events, und der Event-Stream lässt sich direkt an einen Event Store anhängen oder als Audit-Log weiterverwenden. Riverpod folgt einem zustandszentrierten Paradigma und benötigt ein separat aufgesetztes Event-Log. |
Diese Tabelle sollte nicht als „bei dieser Größe zwingend diese Bibliothek“ gelesen werden, sondern als Werkzeug, das fragt: Welche Art von Reibung kann das Team tragen? Selbst eine große Organisation kann sinnvollerweise auf Riverpod setzen, wenn es intern ein starkes Lint-/Architektur-Team gibt, das Scope-Fehler von Riverpod per statischer Analyse abfangen kann.
Ein reales Migrationsbeispiel: Der Umstieg von Provider auf Riverpod
Der Umstieg einer App, die mit dem provider-Paket (aktuell 6.1.5+1) gestartet wurde, auf Riverpod folgt in der Regel diesem Ablauf.
- Root-Wrapping austauschen:
runApp(MultiProvider(providers: [...], child: MyApp()))wird zurunApp(ProviderScope(child: MyApp())). Zu diesem Zeitpunkt ist es risikoärmer, bestehendeChangeNotifierProviderüber die Kompatibilitätsschicht von Riverpod einzubinden und beide parallel laufen zu lassen. - Widget-Typen umstellen: Um Aufrufe von
context.watch<T>()/context.read<T>()zuref.watch(xProvider)/ref.read(xProvider)zu machen, müssenStatelessWidget/StatefulWidgetzuConsumerWidget/ConsumerStatefulWidgetwerden. Das ist mechanisch, erzeugt aber große Diffs – in der Praxis bewährt es sich, die PRs feature-weise in überschaubare, review-fähige Häppchen zu zerlegen. - Scopes neu entwerfen: Die verschachtelte Struktur von
MultiProvider(Scopes pro Tab, pro Listenelement usw.) muss mit der Kombination.family+.autoDisposevon Riverpod neu konzipiert werden. Genau hier tritt das oben beschriebene Problem des Stale State durch falsch eingesetztes autoDispose in der Praxis am häufigsten auf. Es passiert häufig, dass Teams Tutorials wörtlich folgen, autoDispose sogar an Session-Zustand hängen und dadurch der Login-Status zurückgesetzt wird. - Tests neu schreiben: Tests, die Widgets bisher mit
ChangeNotifierProvider<T>.valueumschlossen haben, müssen jetzt mitProviderScope(overrides: [...])umschlossen werden. Bei Apps mit hunderten Widget-Tests macht dieser Teil den größten Anteil des Migrations-Diffs aus. Ein gemeinsamertestProviderScope()-Helfer, vorab angelegt, reduziert die Wiederholungsarbeit. - Schrittweiser Umstieg: Statt eines Big-Bang-Rewrites lässt sich das Risiko in der Praxis besser steuern, wenn man Screen für Screen umstellt: neue Screens laufen unter dem gemeinsamen Root-
ProviderScopebereits auf Riverpod, noch nicht migrierte Screens bleiben vorerst beim bestehendenprovider. Da beide Bibliotheken innerhalb desselben Widget-Baums problemlos koexistieren, muss man den Umstieg nicht erzwungen in einem Rutsch abschließen.
Vergleich der Testbarkeit: Provider-Override vs. bloc_test
Riverpod tauscht Abhängigkeiten aus, indem man dem ProviderContainer overrides übergibt.
test('주문 목록 로드 실패 시 error 상태를 노출한다', () async {
final container = ProviderContainer(
overrides: [
orderRepositoryProvider.overrideWithValue(
FakeOrderRepository()..throwsOnFetch = true,
),
],
);
addTearDown(container.dispose);
await expectLater(
container.read(orderListProvider.future),
throwsA(isException),
);
});
Bloc verifiziert Event-State-Sequenzen deklarativ mit blocTest aus dem Paket bloc_test.
blocTest<OrderBloc, OrderState>(
'fetch 실패 시 OrderError를 emit한다',
build: () {
when(() => repository.fetchOrders()).thenThrow(Exception('network'));
return OrderBloc(repository: repository);
},
act: (bloc) => bloc.add(OrderFetched()),
expect: () => [OrderLoading(), isA<OrderError>()],
);
In der Praxis unterscheiden sich die beiden Ansätze wie folgt.
- Riverpod: Da der Provider selbst bereits als DI-Container fungiert, lässt sich ein bestimmter Knoten im Graphen allein mit
overrideWithValue/overrideWithaustauschen – ganz ohne separate Mocking-Bibliothek. Beim Testen der Kombination.family+.autoDisposeführt ein vergessenescontainer.dispose()allerdings leicht zu Warnungen wegen Ressourcenlecks, oder man trifft nicht die exakte Instanz des zu überschreibenden Providers und stößt auf Fehler der Art „provider not found“. - Bloc: Die Struktur aus
act/expect/verifyinblocTestlässt QA-Teams und Code-Reviewer auf einen Blick erkennen, „welches Event zu welchem State führt“. Da Bloc selbst aber keinen DI-Mechanismus mitbringt, muss die Repository-Schicht über eine separate Mocking-Bibliothek wiemocktailkombiniert mit Constructor Injection (oderget_it) abgedeckt werden. „Event-Sequenz-Tests“ und „Dependency-Mocking“ sind also auf unterschiedliche Werkzeuge verteilt.
Zusammengefasst muss man bei Riverpod bedenken, dass das Scope-Konzept auch in Tests eins zu eins übernommen wird, sodass man dieselben Scope-Fehler, die man in Produktion macht, auch in Tests wiederholen kann; bei Bloc muss man dagegen berücksichtigen, dass die Verifikation von Event-Sequenzen zwar einfach ist, man aber eine separate Dependency-Injection-Infrastruktur aufbauen muss.
Fazit: Entscheidungs-Checkliste nach Codebasis-Größe und Team-Reifegrad
Je mehr der folgenden Punkte ihr mit „Ja“ beantworten könnt, desto besser seid ihr darauf vorbereitet, die Reibung der jeweiligen Bibliothek zu tragen.
Fälle, in denen Riverpod wahrscheinlich die richtige Wahl ist
- Ihr seid ein kleines Team mit höchstens drei Personen oder befindet euch in einer frühen MVP-Phase mit häufig wechselnden Feature-Specs.
- Es gibt jemanden, der die Scope-Regeln für
ProviderScope/autoDisposedokumentiert und in Code-Reviews durchsetzt. - Das Team kann selbst entscheiden, ob Codegen eingesetzt wird, und hat die Kapazität, die
build_runner-Onboarding-Doku zu pflegen. - Screens, die wie Dialoge oder Bottom Sheets ein Navigator-Overlay verwenden, sind selten – oder es gibt für dieses Pattern bereits einen Scope-Leitfaden.
Fälle, in denen Bloc wahrscheinlich die richtige Wahl ist
- Mehrere Squads arbeiten gleichzeitig an derselben Codebasis, und ihr wollt Verträge für Zustandsänderungen explizit im PR-Review festhalten.
- Wie im Finanz-/Commerce-Bereich sind die Events selbst eng an Anforderungen für Audit-Logs oder Replay gekoppelt.
- Es kommen häufig neue Leute ins Team, und es gibt bereits einen Prozess (oder einen Plan dafür), der über Review-Regeln erzwingt, ob ein Event wiederverwendet werden darf.
- Ihr habt die Disziplin, für einfache Screens mit reiner Wertaktualisierung
Cubitzu verwenden undBlocnur dort einzusetzen, wo die Event-Historie tatsächlich Domänenwert besitzt.
Egal für welche Seite ihr euch entscheidet: Nicht die Wahl der Bibliothek selbst entscheidet über Erfolg oder Misserfolg in der Produktion, sondern ob das Team die Disziplin, die die Bibliothek nicht von sich aus erzwingt, selbst aufbauen kann. Bei Riverpod müsst ihr Scope-Disziplin selbst etablieren, bei Bloc Event-Governance-Disziplin. Die Bibliothek danach auszuwählen, auf welcher Seite ihr die Kapazität und den Willen habt, diese Disziplin aufzubauen, ist eine weitaus präzisere Entscheidungsmethode als jede Feature-Vergleichstabelle.