effidevFlutter · Cloudflare-Edge · Cloud-Kostenoptimierung

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

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, showModalBottomSheet oder rootNavigator: true verlassen den ProviderScope-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 riverpod 3.3.2, riverpod_generator 4.0.4, flutter_bloc 9.1.1, bloc_test 10.0.0 und provider 6.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.

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.

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.

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.

  1. Root-Wrapping austauschen: runApp(MultiProvider(providers: [...], child: MyApp())) wird zu runApp(ProviderScope(child: MyApp())). Zu diesem Zeitpunkt ist es risikoärmer, bestehende ChangeNotifierProvider über die Kompatibilitätsschicht von Riverpod einzubinden und beide parallel laufen zu lassen.
  2. Widget-Typen umstellen: Um Aufrufe von context.watch<T>() / context.read<T>() zu ref.watch(xProvider) / ref.read(xProvider) zu machen, müssen StatelessWidget/StatefulWidget zu ConsumerWidget/ConsumerStatefulWidget werden. 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.
  3. Scopes neu entwerfen: Die verschachtelte Struktur von MultiProvider (Scopes pro Tab, pro Listenelement usw.) muss mit der Kombination .family + .autoDispose von 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.
  4. Tests neu schreiben: Tests, die Widgets bisher mit ChangeNotifierProvider<T>.value umschlossen haben, müssen jetzt mit ProviderScope(overrides: [...]) umschlossen werden. Bei Apps mit hunderten Widget-Tests macht dieser Teil den größten Anteil des Migrations-Diffs aus. Ein gemeinsamer testProviderScope()-Helfer, vorab angelegt, reduziert die Wiederholungsarbeit.
  5. 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-ProviderScope bereits auf Riverpod, noch nicht migrierte Screens bleiben vorerst beim bestehenden provider. 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.

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

Fälle, in denen Bloc wahrscheinlich die richtige Wahl ist

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.