By Randal L. Schwartz, and a few million TPU cycles
Motto: "With the rigor of Bloc and the flex and speed of Signal"
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.
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) ────────┘
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!
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
In BlocSignal
, bloc.state
is natively a ** ReadonlySignal<State>**. State propagation is
// BlocSignal: Synchronous signal graph propagation
emit(newState); // Downstream computeds & UI elements update IN THE EXACT SAME FRAME
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.
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.
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,
);
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();
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.
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);
💡
No Retain Count Leaks: Passingref
(orWidgetRef
/ProviderContainer
) automatically registersref.onDispose
teardown hooks. When the parent Riverpod container or widget unmounts, the underlyingBlocSignal
is closed automatically.
BlocSignal
to Riverpod
Turn any BlocSignal
into a standard Riverpod NotifierProvider
:
final NotifierProvider<Notifier<CounterState>, CounterState> riverpodProvider =
myCounterBloc.toProvider();
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();
Listenable
& package:provider
(package:bloc_signals_flutter
)
Flutter's native ChangeNotifier
, ValueNotifier
, and package:provider
remain heavily used across thousands of codebases.
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,
);
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'),
),
);
Because every adapter maps cleanly to BlocSignalBase
, you can route state across multiple track lines in a single, synchronous pipeline!
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();
State changes in myChangeNotifier
propagate synchronously through formCubit into Riverpod in the exact same frame, with zero frame lag or microtask queuing!
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
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:
BlocSignal
API contracts without hallucination.ref.onDispose
, unawaited(super.onEvent(event))
).blocSignalTest
suites with 100% line coverage.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.
| 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 |
Check out the official open-source packages and AI skills on GitHub:
pub.dev/packages/bloc_signals
pub.dev/packages/bloc_signals_flutter
pub.dev/packages/bloc_signals_riverpod
pub.dev/packages/bloc_signals_test
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! 🚂