close

DEV Community

Cover image for Why BlocSignal Doesn't Need Provider (And Why Classic BLoC Always Did)
Randal L. Schwartz for Google Developer Experts

Posted on Edited on

Why BlocSignal Doesn't Need Provider (And Why Classic BLoC Always Did)

How shedding package:provider eliminates dependency hell, fixes Flutter's lingering ghost rebuild bug, and delivers fine-grained synchronous reactivity in 2026.

If you browse r/FlutterDev on any given week, you will find the exact same architectural debate playing out:

"Should I use BLoC or Riverpod for my next production app? BLoC has great structure and discipline, but the stream boilerplate is overwhelming. Riverpod is reactive and flexible, but the @riverpod code generation and constant version transitions make it feel heavyweight."

And inevitably, someone in the comments will chime in:

"I just stick with plain package:provider because it's simple and doesn't require code-gen."

This trilemmaβ€”BLoC vs. Riverpod vs. Providerβ€”has defined Flutter state management for over six years. But behind this debate lies a little-known architectural secret that explains why Flutter state management felt so fractured in the first place:

Classic flutter_bloc was secretly just package:provider in disguise.

Let's look at why classic BLoC relied on package:provider, the hidden runtime bugs and dependency deadlocks that came with it, why Riverpod had to break away, and how BlocSignal delivers the ultimate resolution: zero provider, zero streams, and zero code generation.


1. Look Under the Hood: Classic BLoC's Hidden Dependency

When developers think of Felix Angelov’s classic flutter_bloc, they think of Streams, Sinks, and unidirectional event architectures. But if you open flutter_bloc/pubspec.yaml, you'll find a foundational dependency:

dependencies:
  bloc: ^8.1.4
  provider: ^6.0.5 # πŸ‘ˆ The hidden foundation!
Enter fullscreen mode Exit fullscreen mode

In classic flutter_bloc:

  • BlocProvider<T> is literally an extension of package:provider's InheritedProvider.
  • MultiBlocProvider is just a thin alias over MultiProvider.
  • RepositoryProvider is literally Provider<T>.

Why Did Classic BLoC Do This?

Back in 2018–2019, writing custom InheritedWidget plumbing in Flutter was verbose and error-prone. RΓ©mi Rousselet’s package:provider was the newly crowned Google-recommended solution for dependency injection and widget tree scoping.

Building flutter_bloc on top of package:provider allowed BLoC to focus on its stream state machine while outsourcing widget tree scoping, lazy instantiation, and disposal to Provider.

It seemed like a great shortcut. But over time, coupling BLoC to package:provider introduced two massive architectural headaches.


2. The Two Fatal Flaws of the Provider Foundation

[ Your Application ] ──► [ flutter_bloc ]
                            β”‚
                            └──► [ package:provider ] ──► [ Transitive Version Lock ]
Enter fullscreen mode Exit fullscreen mode

Flaw #1: "Dependency Hell" & Version Lockouts

Because package:provider is one of the most widely used packages in the Flutter ecosystem, major version updates (such as migrating from v4 to v5 to v6 for null safety) created widespread dependency deadlocks:

Because my_app depends on:
  - legacy_auth_plugin ^2.1.0 (which depends on provider ^5.0.0)
  - flutter_bloc ^8.0.0 (which depends on provider ^6.0.5)

Version solving failed:
Cannot solve dependencies because provider ^5.0.0 is incompatible with provider ^6.0.5!
Enter fullscreen mode Exit fullscreen mode

Every Flutter developer has experienced this nightmare:

  • You couldn't upgrade flutter_bloc because an analytics or payment SDK pinned an older provider.
  • Teams were forced to use risky dependency_overrides: in pubspec.yaml and pray that internal breaking changes wouldn't crash production builds.
  • Engineers had to fork third-party repositories just to bump a provider constraint.

Flaw #2: The "Lingering Dependency" (Ghost Rebuild) Bug

This is the deepest, most subtle flaw in Flutter's InheritedWidget systemβ€”and it was the primary catalyst that drove RΓ©mi Rousselet to abandon Provider and create Riverpod.

When an Element calls context.watch<T>() or Provider.of<T>(context), Flutter registers that Element as a dependent of the ancestor InheritedWidget.

The fatal catch: Flutter’s engine never unregisters an element from an InheritedWidget on subsequent builds! Dependencies are only cleared when the widget is completely unmounted.

Consider this common conditional UI pattern:

// πŸ‘΄ The Classic Provider Ghost Rebuild Trap:
Widget build(BuildContext context) {
  if (isExpanded) {
    // 🚩 Registers a permanent dependency on DetailsModel
    final details = Provider.of<DetailsModel>(context);
    return FullDetailsCard(details);
  } else {
    // πŸ‘» GHOST REBUILD: Even when collapsed, this widget STILL rebuilds 
    // on every single change to DetailsModel forever!
    return const CompactSummaryCard();
  }
}
Enter fullscreen mode Exit fullscreen mode

Once isExpanded is true even once, Flutter permanently binds DetailsModel to that widget. When the card collapses, it continues to rebuild on every DetailsModel emission indefinitely, wasting CPU cycles, battery, and rendering frames on state it isn't even displaying!


3. The Riverpod Exodus: Escaping the Widget Tree

RΓ©mi recognized that Flutter's InheritedWidget and BuildContext had fundamental limitations that could not be fixed within package:provider:

  1. You couldn't easily read state outside the widget tree (for example, in background services or pure Dart logic).
  2. The lingering dependency bug caused unavoidable ghost rebuilds on conditional branches.
  3. Combining two providers required ugly nested widget hierarchies or manual proxies.

So RΓ©mi built Riverpod (ProviderContainer), moving the entire dependency and state graph outside of the Flutter widget tree.

Where Riverpod Got Complicated

While Riverpod solved the BuildContext coupling, it created a new set of challenges:

  • The @riverpod Code-Gen Dogmatism: To avoid writing boilerplate notifiers, developers were pushed toward build_runner and code generation. If you didn't run a file watcher in the background, development ground to a halt.
  • Complex Internal Types: Behind a simple provider was a labyrinth of generated classes (AutoDisposeAsyncNotifierProviderElement, ProviderFamily, AsyncValue edge cases).
  • Two-World Impedance: Managing state in an external container while rendering in Flutter's Element tree required complex retention counters (autoDispose, disposeDelay, cacheTime) to guess when widgets were truly done using state.

4. The BlocSignal Resolution: Zero Provider, Zero Code-Gen

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                              BlocSignal                                β”‚
β”‚                                                                        β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚   β”‚   The Rigor of BLoC    β”‚              β”‚  The Speed of Signals  β”‚   β”‚
β”‚   β”‚  β€’ Unidirectional flow β”‚              β”‚  β€’ Synchronous DAG     β”‚   β”‚
β”‚   β”‚  β€’ Explicit Events     β”‚      βž•      β”‚  β€’ Dynamic Pruning     β”‚   β”‚
β”‚   β”‚  β€’ Strict Transitions  β”‚              β”‚  β€’ Zero Streams        β”‚   β”‚
β”‚   β”‚  β€’ 100% Traceability   β”‚              β”‚  β€’ Zero Code-Gen       β”‚   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

BlocSignal resolves this historical progression by rethinking the state primitive from the ground up:

1. Native O(1) InheritedWidget (Zero Third-Party Dependencies)

In bloc_signals_flutter, BlocSignalProvider does not depend on package:provider.

  • It is built directly on Flutter's core SDK InheritedWidget.
  • It performs instant O(1) lookups via getElementForInheritedWidgetOfExactType without intermediate proxy nodes or delegating elements.
  • It has zero external dependenciesβ€”eliminating pub get version deadlocks permanently.

2. Dynamic Per-Frame Dependency Pruning (No Ghost Rebuilds)

Because BlocSignal is powered by fine-grained Signals (signals_flutter), dependencies are tracked dynamically on every single evaluation frame:

// ⚑ In BlocSignal: Zero Ghost Rebuilds!
Widget build(BuildContext context) {
  return Watch((context) {
    if (isExpanded.value) {
      // βœ… Subscribes to detailsCubit in this frame
      return FullDetailsCard(detailsCubit.state.value);
    }
    // βœ… When false, detailsCubit is AUTOMATICALLY UNWATCHED and detached!
    return const CompactSummaryCard();
  });
}
Enter fullscreen mode Exit fullscreen mode

When isExpanded turns false, detailsCubit is immediately pruned and unwatched. If detailsCubit mutates while the card is collapsed, zero rebuilds occur. You get pristine, leak-free reactivity without code generation or external containers.


3. Synchronous State Propagation (No Stream Queue Latency)

Classic BLoC emits state over Dart asynchronous microtask Streams. Every state change yields to the event loop before reaching the screen.

In BlocSignal:

  • Calling emit(newState) updates the underlying ReadonlySignal<State> synchronously in the exact same frame.
  • The GPU and widget tree render the new state with zero microtask queue hops and zero 1-frame loading flickers.

4. Streamless BLoC-to-BLoC Coordination

In classic BLoC, coordinating two Blocs requires nesting BlocListener widgets in the UI tree or writing complex Rx stream pipelines.

In BlocSignal, because state is a Signal, containers can observe each other directly in pure business logic:

class CartCubit extends CubitSignal<CartState> {
  CartCubit(this.authCubit) : super(CartInitial()) {
    // Synchronously react to auth changes without UI BlocListeners:
    createEffect(() {
      if (authCubit.state.value is Unauthenticated) {
        clearCart();
      }
    });
  }

  final AuthCubit authCubit;
}
Enter fullscreen mode Exit fullscreen mode

5. The 4-Way Code Shootout

Let's look at how the exact same Counter feature looks across all four paradigms:

Option A: Classic Provider (ChangeNotifier)

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners(); // 🚩 Easy to forget; triggers blanket rebuilds
  }
}
Enter fullscreen mode Exit fullscreen mode

Option B: Classic flutter_bloc (Streams + package:provider)

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1); // ⏳ Asynchronous stream microtask
}
Enter fullscreen mode Exit fullscreen mode

Option C: Riverpod 3 (Code Generation + build_runner)

@riverpod
class Counter extends _$Counter {
  @override
  int build() => 0;

  void increment() => state++; // βš™οΈ Requires running build_runner
}
Enter fullscreen mode Exit fullscreen mode

Option D: Modern BlocSignal (Pure, Synchronous Dart)

In Dart 3.5 (Baseline Syntax):

class CounterCubit extends CubitSignal<int> {
  CounterCubit([int initial = 0]) : super(initialState: initial);
  void increment() => emit(stateValue + 1); // ⚑ Synchronous, zero code-gen
}
Enter fullscreen mode Exit fullscreen mode

In Dart 3.13 (Modern Primary Constructor):

class CounterCubit([int initial = 0]) extends CubitSignal<int> {
  this : super(initialState: initial);
  void increment() => emit(stateValue + 1);
}
Enter fullscreen mode Exit fullscreen mode

6. The Ultimate Comparison Matrix

Feature package:provider Classic flutter_bloc Riverpod 3 BlocSignal
Core Reactive Engine ChangeNotifier Asynchronous Stream External DAG Synchronous Signal DAG
Depends on provider? N/A YES (^6.0.0) No NO (Pure SDK)
Requires Code-Gen? No No YES (@riverpod) ZERO Code-Gen
Ghost Rebuild Fix? ❌ (Leaks on branch) ❌ (Inherited leak) βœ… (External Graph) βœ… (Dynamic Graph Pruning)
State Immutability ❌ (Mutable fields) βœ… (Immutable State) βœ… (Immutable State) βœ… (ReadonlySignal)
Execution Timing Synchronous Asynchronous microtask Synchronous Synchronous (Same Frame)
OpenTelemetry & Observers ❌ βœ… (BlocObserver) Partial (ProviderObserver) βœ… (Otel + Observers)
Cross-Container Sync Clunky Proxies Nested UI Listeners ref.watch() createEffect / computed

7. The 60-Second Refactor: Your AI Migration Playbook

Five years ago, migrating a production app away from classic BLoC or Provider was a multi-month engineering slog.

In 2026, with modern AI coding assistants (Antigravity, Cursor, Copilot, Gemini) and BlocSignal, the refactor is practically instantaneous:

PROMPT FOR YOUR AI ASSISTANT:
"Replace `flutter_bloc` with `bloc_signals_flutter`.
Replace `BlocProvider` with `BlocSignalProvider`.
Replace `BlocBuilder` with `BlocSignalBuilder`.
Remove `provider` from `pubspec.yaml` and run `flutter pub get`."
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Automated AI Migration Skills: The BlocSignal repository even includes pre-packaged AI Agent Skills & Plugins (under plugins/bloc-signals/skills/bloc-signals/) for Antigravity, Cursor, Gemini CLI, and Claude Code. You can install the skill into your workspace to give your AI agent deep, rule-enforced expertise in migrating classic BLoC and Riverpod apps to BlocSignal with full test verification!

In 60 seconds:

  1. All your Blocs and Cubits keep their exact same event and state models.
  2. The asynchronous stream microtask delay disappears.
  3. package:provider is wiped from your pubspec.yaml forever.
  4. Your unit tests run synchronously with zero pumpAndSettle() microtask draining hacks.

8. Summary: Less is More

Software architecture advances not by adding more layers of abstraction, but by removing the friction between your code and the metal.

By removing package:provider and replacing stream plumbing with fine-grained Signals:

  • You eliminate version collisions and dependency solver deadlocks.
  • You fix Flutter’s lingering dependency ghost rebuild bug.
  • You get synchronous, same-frame UI rendering.
  • You preserve 100% of BLoC’s enterprise structure and event traceability.

It's everything you loved about BLoC, everything you wanted from Riverpod, and all the simplicity of Providerβ€”with none of the baggage.


πŸš€ Get Started with BlocSignal

Top comments (4)

Collapse
 
alexshev profile image
Alex Shev

The implementation detail that matters most is making the assumption visible. For this kind of work I would put the invariant in CI or monitoring, then document the recovery path alongside it. That is how a one-time fix becomes a reliable operating practice.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts

Couldn’t agree more, Alex. Making implicit assumptions explicit is the core difference between a temporary patch and a scalable engineering practice.

In the case of transitive dependency hell or ghost rebuilds, the biggest danger was that the system failed silently:

Compile-time vs. Runtime Invariants: If a pattern has known footguns, it shouldn't rely on developer memoryβ€”it belongs in custom analyzer lints in CI (for example enforcing disposal rules or detecting unsafe transitive dependencies).
Observability & Recovery: When edge cases occur in production, having structured telemetry (like OpenTelemetry state transition tracking) provides the exact timeline and recovery path needed to debug without guesswork.
Shifting the burden from human vigilance to automated tooling and documented operating procedures is how large codebases stay maintainable over years. Thanks for bringing this perspective!

Collapse
 
alexshev profile image
Alex Shev

The question is not whether a state tool is present, but where its dependency boundary lives. A local, explicit boundary can make a component easier to test and reuse; the trade-off is simply being honest about how state is supplied across the app.

Thread Thread
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts

Spot on, Alex. Dependency boundaries dictate testability, refactorability, and long-term modularity far more than the specific syntax of a state container. The recurring tension in Flutter architecture has always been between ambient implicit context resolution and explicit dependency supply.

In designing BlocSignal, the primary goal was to bring total honesty to those boundaries by cleanly decoupling the state machine & reactive DAG from the dependency injection & scoping mechanism:

  1. Explicit Domain Boundaries (Pure Dart / Constructor Injection):
    At the business logic layer, a BlocSignal or CubitSignal is a pure Dart class with no Flutter dependencies. Inter-Bloc dependencies are passed directly through standard Dart constructors (for example, CartCubit(this.authCubit)). This keeps domain boundaries 100% explicit and enables unit testing without mocking framework containers, ambient singletons, or widget trees.

  2. Explicit Subtree Boundaries (Presentation & Lifecycle Scoping):
    At the widget level, state that belongs to a specific route or modal is scoped using BlocSignalProvider (built purely on Flutter's native InheritedWidget). The lifecycle boundary is strictly tied to the widget tree: when the subtree unmounts, the container is disposed.

  3. Fine-Grained Reactivity Across the Boundary:
    Where classic approaches conflated where state is supplied (scoping) with how state notifies listeners (broad widget invalidation or stream subscriptions), Signals decouple the two. You define the boundary explicitly (via constructor or provider), while the underlying reactive graph handles frame-by-frame dynamic pruning within that boundaryβ€”preventing subscription leaks and ghost rebuilds.

Being explicit about whether a boundary is domain-level (constructor-injected Dart objects) or presentation-level (subtree-scoped providers) avoids the trap of forcing everything into an ambient global bag or forcing pure business logic into the widget tree.