DEV Community

Cover image for Switching Tracks in BlocSignal: The Universal State Switchyard for BLoC, Riverpod, and Provider

Switching Tracks in BlocSignal: The Universal State Switchyard for BLoC, Riverpod, and Provider

By Randal L. Schwartz, and a few million TPU cycles

Motto: "With the rigor of Bloc and the flex and speed of Signal"

Why You Don't Have to Tear Up Your Codebase to Enjoy the Speed of Synchronous Signals

If you have followed my talks, articles, or comments in the Flutter community over the years, you know I have been a strong advocate for Riverpod. Riverpod solved many of the fundamental global-state scoping issues inherent in classic InheritedWidget patterns, providing compile-safe dependency injection and clean state isolation.

However, as the Flutter ecosystem evolved toward Riverpod 3, I grew increasingly wary of the direction being pushed: a heavy reliance on mandatory code generation (@riverpod annotations, build_runner, macros). Code generation introduces build-step friction, bloats compile times, and makes debugging generated syntax opaque.

On the other side of the tracks sat BLoC. While I appreciated BLoC's structured, predictable event-to-state machine pattern (on<Event>), I was never a big fan of classic BLoC's reliance on underlying Dart Streams. Streams operate asynchronously via microtask queuesβ€”introducing subtle frame-rendering latencyβ€”and require extensive stream-transformer ceremony for simple state updates.

Then came Signals (specifically Rody Davis's signals package). Signals brought raw speed, zero microtask overhead, fine-grained composable reactivity, and pure Dart portability.

That realization birthed BlocSignal: combining BLoC's disciplined, enterprise event-state architecture with Signals' synchronous reactivity.

And more importantly, it solved the single biggest pain point in Flutter development: the migration trap.


πŸš‚ The Core Metaphor: "Switching Tracks in BlocSignal"

In Flutter development, choosing a state management tool often feels like choosing a railroad company. If your team built an application on package:provider or flutter_bloc and wants to adopt Riverpod or Signals, traditional wisdom dictates a nightmare: tearing up all your existing tracks and rebuilding your rail lines from scratch.

BlocSignal rejects this all-or-nothing trap. Named after the classic railway block signalβ€”the system that manages train traffic safely to prevent collisionsβ€”BlocSignal acts as a central railway switchyard.

  Classic BLoC Line (Streams) ─────────┐
                                       β”‚
  Riverpod Express (Providers) ────────┼──► [ BlocSignal Central Switchyard ] ◄──► Pure Reactive Signals
                                       β”‚
  Provider Local (Listenables) β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

You do not need to abandon your existing rail network or halt traffic. Whether your feature runs on the Riverpod Express, BLoC Stream Line, or Flutter's native ChangeNotifier local track, you can switch tracks synchronously at the BlocSignal switchyard and keep your train moving smoothly!


⚑ The Synchronous Signal Core: Green Lights All the Way

In classic flutter_bloc, state updates flow through StreamController microtask queues:

// Classic BLoC: Asynchronous microtask queue scheduling
emit(newState); // State arrives on the next microtask tick
Enter fullscreen mode Exit fullscreen mode

In BlocSignal, bloc.state is natively a ReadonlySignal<State>. State propagation is 100% synchronous:

// BlocSignal: Synchronous signal graph propagation
emit(newState); // Downstream computeds & UI elements update IN THE EXACT SAME FRAME
Enter fullscreen mode Exit fullscreen mode

Because ReadonlySignal is a lightweight, primitive reactive node, external state objects (Stream, ProviderListenable, ValueListenable) can be adapted into a BlocSignal (or vice-versa) with zero-cost bridge wrappers.

Furthermore, all BlocSignal interop bridges support custom equality comparators (equals: (prev, next) => ...), preserving strict state de-duplication rules across track switches.

Let's walk through the three main rail lines.


πŸš† Track 1: BLoC, Redux & Stream Interop (package:bloc_signals)

If you have legacy BLoCs, Redux stores, or RxDart observables, you can bridge them onto the BlocSignal switchyard without breaking existing stream subscribers.

Ingesting Streams & Redux

Use StreamBlocSignal to turn any Dart Stream into a BlocSignalBase container:

import 'package:bloc_signals/bloc_signals.dart';

// 1. Adapt a standard Dart Stream / RxDart Observable
final streamCubit = StreamBlocSignal<int>(
  stream: myLegacyStream,
  initialState: 0,
);

// 2. Adapt a Redux Store
final reduxCubit = StreamBlocSignal<AppState>(
  stream: reduxStore.onChange,
  initialState: reduxStore.state,
);
Enter fullscreen mode Exit fullscreen mode

Exporting BlocSignal to Stream

Need to feed a legacy StreamBuilder or BlocBuilder? Expose any BlocSignal as a standard Dart stream:

final Stream<MyState> stream = myBlocSignal.toStream();
Enter fullscreen mode Exit fullscreen mode

πŸš† Track 2: Riverpod Interop (package:bloc_signals_riverpod)

For developers using Riverpod for dependency injection who want to avoid code generation and microtask delays, package:bloc_signals_riverpod provides zero-boilerplate bidirectional adapters.

Ingesting Riverpod Providers (With Automatic Teardown!)

Convert any Riverpod ProviderListenable into a BlocSignal:

import 'package:bloc_signals_riverpod/bloc_signals_riverpod.dart';

// Ingest a Riverpod provider into BlocSignal
// Automatically binds ref.onDispose(bloc.close) under the hood!
final userBloc = userProvider.toBlocSignal(ref);
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ No Retain Count Leaks: Passing ref (or WidgetRef / ProviderContainer) automatically registers ref.onDispose teardown hooks. When the parent Riverpod container or widget unmounts, the underlying BlocSignal is closed automatically.

Exporting BlocSignal to Riverpod

Turn any BlocSignal into a standard Riverpod NotifierProvider:

final NotifierProvider<Notifier<CounterState>, CounterState> riverpodProvider = 
    myCounterBloc.toProvider();
Enter fullscreen mode Exit fullscreen mode

Riverpod 3 AsyncValue ↔ Signals AsyncState Bridge

Riverpod 3 introduced sealed AsyncValue classes. bloc_signals_riverpod includes seamless conversions:

final AsyncState<UserData> signalsState = riverpodAsyncValue.toAsyncState();
final AsyncValue<UserData> riverpodValue = signalsAsyncState.toAsyncValue();
Enter fullscreen mode Exit fullscreen mode

🚈 Track 3: Flutter Listenable & package:provider (package:bloc_signals_flutter)

Flutter's native ChangeNotifier, ValueNotifier, and package:provider remain heavily used across thousands of codebases.

Ingesting Listenable and ValueNotifier

Convert any Flutter Listenable directly into a BlocSignalBase:

import 'package:bloc_signals_flutter/bloc_signals_flutter.dart';

// ValueNotifier -> BlocSignal
final countCubit = myValueNotifier.toBlocSignal();

// ChangeNotifier -> BlocSignal
final authCubit = myChangeNotifier.toBlocSignal(
  readState: () => myChangeNotifier.currentUser,
);
Enter fullscreen mode Exit fullscreen mode

Exporting BlocSignal to ValueListenable

Expose any BlocSignal as a standard Flutter ValueListenable<T>:

final ValueListenable<int> listenable = myBlocSignal.toValueListenable();

// Consume via package:provider's ValueListenableProvider
ValueListenableProvider<int>.value(
  value: listenable,
  child: Consumer<int>(
    builder: (context, count, _) => Text('Count: $count'),
  ),
);
Enter fullscreen mode Exit fullscreen mode

πŸ”„ Multi-Track Routing: Synchronizing Across Ecosystems

Because every adapter maps cleanly to BlocSignalBase, you can route state across multiple track lines in a single, synchronous pipeline!

Example: Provider βž” BlocSignal βž” Riverpod

Imagine a legacy ChangeNotifier form field that needs to be consumed by a new Riverpod feature:

// 1. Ingest Flutter ChangeNotifier into a BlocSignal Cubit
final formCubit = myChangeNotifier.toBlocSignal(
  readState: () => myChangeNotifier.formValue,
);

// 2. Export the Cubit directly as a Riverpod NotifierProvider
final riverpodFormContainer = formCubit.toProvider();
Enter fullscreen mode Exit fullscreen mode

State changes in myChangeNotifier propagate synchronously through formCubit into Riverpod in the exact same frame, with zero frame lag or microtask queuing!


πŸ€– AI Agent Skills: Your Automated Signal Operator

One of the most exciting aspects of modern Dart and Flutter development is AI-assisted coding. However, generic AI assistants often generate outdated, deprecated, or incorrect state management patterns.

To solve this, every package in the BlocSignal ecosystem ships with dedicated AI Agent Skills (plugins/bloc-signals/skills/bloc-signals/).

plugins/bloc-signals/skills/bloc-signals/
β”œβ”€β”€ SKILL.md                 # Master agent skill entrypoint
β”œβ”€β”€ architecture.md          # Core synchronous signal graph invariants
β”œβ”€β”€ event_handlers.md        # Concurrency transformers (droppable, sequential)
β”œβ”€β”€ testing.md               # Declarative testing with bloc_signals_test
└── interoperability.md      # Ecosystem bridge matrix & migration recipes
Enter fullscreen mode Exit fullscreen mode

When you use AI coding assistants equipped with these skills (such as Gemini, Antigravity, Claude, Cursor, or Context7), your AI agent acts like an expert signal operator:

  1. Resolves accurate, up-to-date BlocSignal API contracts without hallucination.
  2. Applies correct teardown rules (ref.onDispose, unawaited(super.onEvent(event))).
  3. Generates declarative blocSignalTest suites with 100% line coverage.
  4. Provides instant, step-by-step migration recipes from legacy BLoC or Riverpod codebases.

🎯 Summary & The Interoperability Matrix

State management should serve your projectβ€”not lock you into a rigid framework. With BlocSignal, you retain the architectural discipline of BLoC state machines, gain the raw speed of synchronous Signals, and bridge your existing Riverpod or Provider codebases effortlessly.

πŸ“‹ Cheat-Sheet Interoperability Matrix

Source Paradigm From Source βž” BlocSignal From BlocSignal βž” Target Target Package
BLoC / Stream StreamBlocSignal(stream) blocSignal.toStream() bloc_signals
Redux StreamBlocSignal(store.onChange) blocSignal.toStream() bloc_signals
Riverpod provider.toBlocSignal(ref) blocSignal.toProvider() bloc_signals_riverpod
Provider (Listenable) listenable.toBlocSignal() blocSignal.toValueListenable() bloc_signals_flutter
Riverpod Async asyncValue.toAsyncState() asyncState.toAsyncValue() bloc_signals_riverpod

πŸš€ Get Started Today

Check out the official open-source packages and AI skills on GitHub:

  • πŸ“¦ GitHub Repository: github.com/RandalSchwartz/BlocSignal
  • πŸ› οΈ Core Package: pub.dev/packages/bloc_signals
  • πŸ“± Flutter UI Package: pub.dev/packages/bloc_signals_flutter
  • 🌊 Riverpod Interop: pub.dev/packages/bloc_signals_riverpod
  • πŸ§ͺ Testing Package: pub.dev/packages/bloc_signals_test
  • πŸ€– AI Agent Skill Bundle: plugins/bloc-signals/skills/bloc-signals

Stop tearing up your tracks. Step up to the switchyard, switch tracks with BlocSignal, and keep your Flutter trains running on time! πŸš‚

Top comments (0)