Introduction: Combining Riverpod Safety with Zero Codegen
If you've been building Flutter apps with Riverpod, you already know the joy of compile-time provider safety, auto-disposal, and synchronous notification propagation via ProviderListenable. Riverpod fundamentally raised the bar for Flutter state management.
However, as projects grow, many Riverpod developers run into familiar friction points:
-
Code Generation Overhead: Depending heavily on
riverpod_generatorand waiting onbuild_runnerwatches during rapid UI iteration. -
Complex Provider Trees: Managing nested
ProviderScopeoverrides and.familycache eviction policies when scaling large teams. - All-or-Nothing Migration Concerns: Wanting to trial new reactive primitives or event-driven BLoC architectures without rewriting an entire codebase.
What if you could combine the compile-time safety and synchronous reactivity you love in Riverpod with zero code generation, fine-grained signal graph reactivity, and the ability to trial or migrate incrementally screen-by-screen?
Enter BlocSignal and bloc_signals_riverpod.
The Core Ergonomics: Riverpod Notifier vs. CubitSignal
Let's compare the classic Todos application—one of the benchmark examples in the official Riverpod monorepo—ported directly to BlocSignal.
The Riverpod Approach (Requires @riverpod & build_runner)
In modern Riverpod, creating a todo list with reactive filtering typically involves a generated Notifier and separate provider getters or ref.watch selectors:
@riverpod
class TodoList extends _$TodoList {
@override
List<Todo> build() => const [];
void addTodo(String description) {
state = [...state, Todo(id: DateTime.now().toString(), description: description)];
}
void toggle(String id) {
state = [
for (final todo in state)
if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo
];
}
}
// Derived filter provider
@riverpod
List<Todo> filteredTodos(Ref ref) {
final todos = ref.watch(todoListProvider);
final filter = ref.watch(todoFilterProvider);
return switch (filter) {
TodoFilter.all => todos,
TodoFilter.active => todos.where((t) => !t.completed).toList(),
TodoFilter.completed => todos.where((t) => t.completed).toList(),
};
}
The BlocSignal Approach (100% Pure Dart 3, No Codegen)
With BlocSignal, your state container is a standard handwritten Dart class (CubitSignal<List<Todo>>). Reactive derivations like filteredTodos and uncompletedCount are declared inline using signals computed():
import 'package:bloc_signals/bloc_signals.dart';
import 'package:flutter/foundation.dart';
import 'package:signals_core/signals_core.dart';
class TodosCubit extends CubitSignal<List<Todo>> {
// Pass `equals: listEquals` to enforce value-based list equality for state de-duplication!
TodosCubit([List<Todo> initialTodos = const []])
: super(initialState: initialTodos, equals: listEquals) {
// 1. Reactive filter signal
filter = signal(TodoFilter.all);
// 2. Synchronously derived computed signals
filteredTodos = computed(() {
return switch (filter.value) {
TodoFilter.all => stateValue,
TodoFilter.active => stateValue.where((t) => !t.completed).toList(),
TodoFilter.completed => stateValue.where((t) => t.completed).toList(),
};
});
uncompletedCount = computed(() => stateValue.where((t) => !t.completed).length);
}
late final Signal<TodoFilter> filter;
late final ReadonlySignal<List<Todo>> filteredTodos;
late final ReadonlySignal<int> uncompletedCount;
void addTodo(String description) {
emit([...stateValue, Todo(id: DateTime.now().toString(), description: description)]);
}
void toggle(String id) {
emit([
for (final todo in stateValue)
if (todo.id == id) todo.copyWith(completed: !todo.completed) else todo
]);
}
void setFilter(TodoFilter newFilter) => filter.value = newFilter;
@override
Future<void> close() async {
filter.dispose();
filteredTodos.dispose();
uncompletedCount.dispose();
await super.close();
}
}
What Changed?
-
No
build_runner: No.g.dartgenerated files, no background watchers, no build step delays. -
Built-In Custom Equality (
equals: listEquals): Because DartListinstances don't override==by default, passingequals: listEqualsconfigures the underlying signal graph to de-duplicate state emissions based on list content equality. -
Fine-Grained Signal Graph: Updates to
filter.valueor callingemit(...)re-evaluatecomputed()derivations synchronously and notify only dependent widgets. - Explicit Container Lifecycles: Disposing the cubit disposes its internal signals automatically.
Zero Risk: Incremental Trial & Bidirectional Interop
You do not need to rewrite your application to try BlocSignal. With the bloc_signals_riverpod package, you can seamlessly bridge the two frameworks in both directions.
dependencies:
bloc_signals: ^1.0.0
bloc_signals_flutter: ^1.0.0
bloc_signals_riverpod: ^1.0.0
Option A: Expose a BlocSignal / CubitSignal to Existing Riverpod Widgets
Want to write a new feature or state controller with BlocSignal, but keep your existing Riverpod UI layer (ConsumerWidget, WidgetRef)?
Simply call .toProvider():
import 'package:bloc_signals_riverpod/bloc_signals_riverpod.dart';
// Create your new BlocSignal or CubitSignal
final todosCubit = TodosCubit();
// Convert it directly into a Riverpod NotifierProvider!
final todosProvider = todosCubit.toProvider();
// Now consume it anywhere in existing Riverpod widgets:
class LegacyRiverpodWidget extends ConsumerWidget {
const LegacyRiverpodWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Rebuilds reactively whenever todosCubit emits!
final todos = ref.watch(todosProvider);
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, i) => Text(todos[i].description),
);
}
}
Option B: Adapt an Existing Riverpod Provider into BlocSignal
Have a legacy Riverpod provider that you need to read from a new BlocSignalBuilder widget?
Use .toBlocSignal(ref):
import 'package:bloc_signals_riverpod/bloc_signals_riverpod.dart';
class NewFeatureWidget extends ConsumerWidget {
const NewFeatureWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Adapt any Riverpod ProviderListenable into a BlocSignal container!
// Automatically binds ref.onDispose to close the container when disposed.
final todosBloc = legacyRiverpodProvider.toBlocSignal(ref);
return BlocSignalBuilder<BlocSignalBase<List<Todo>>, List<Todo>>(
bloc: todosBloc,
builder: (context, todos) {
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, i) => Text(todos[i].description),
);
},
);
}
}
Converting AsyncValue <-> AsyncState
bloc_signals_riverpod also provides extension methods to map seamlessly between Riverpod's AsyncValue and Signals' AsyncState:
// Riverpod AsyncValue to Signals AsyncState
final asyncState = riverpodAsyncValue.toAsyncState();
// Signals AsyncState to Riverpod AsyncValue
final asyncValue = signalsAsyncState.toAsyncValue();
Explore the Official Riverpod Ports on blocsignal.dev
To prove the DX gains and side-by-side equivalence, we've ported canonical state management examples directly from the official rrousselGit/riverpod monorepo into the BlocSignal open-source repository:
- 📝 Riverpod Todos (
examples/riverpod_todos):- Replaces
@riverpodcodegen andNotifierwithCubitSignal<List<Todo>>and synchronouscomputed()signals for reactive filter tabs and stats.
- Replaces
- 🔍 Pub.dev Package Search (
examples/riverpod_pub):- Replaces Riverpod
AsyncNotifierwithBlocSignaland a streamlessrestartable()event transformer that automatically cancels in-flight API requests on keypresses without Rx streams.
- Replaces Riverpod
- 🦸 Marvel Character Browser (
examples/riverpod_marvel):- Demonstrates API pagination, character search, and widget tree scoping via
BlocSignalProvider.value.
- Demonstrates API pagination, character search, and widget tree scoping via
Explore all 20 side-by-side benchmark ports across BLoC, Signals, and Riverpod live at blocsignal.dev/#ported-examples!
Built-In AI Agent Skills for Automated Migration
If you use AI coding assistants like Antigravity, Gemini, Cursor, or GitHub Copilot, BlocSignal publishes a dedicated Agent Plugin skill bundle (riverpod_migration.md).
When your AI assistant inspects a project with BlocSignal skills enabled, it automatically understands:
- How to map
StateNotifierProvider/NotifierProvidertoCubitSignal; - How to preserve auto-disposal and cancellation contracts;
- How to refactor
ConsumerWidgetrebuild boundaries toBlocSignalBuilderorSignalBuilder; - How to apply
bloc_signals_riverpodinterop adapters during multi-phase refactoring.
Summary
You don't need to throw away your existing architecture to enjoy the speed, simplicity, and zero-codegen elegance of reactive signals.
With bloc_signals_riverpod, you can trial BlocSignal on a single screen today, bridge your existing Riverpod providers seamlessly, and upgrade your developer experience at your own pace.
- 🌐 Website & Comparison Benchmarks: blocsignal.dev
- 📦 Pub.dev Packages:
bloc_signals|bloc_signals_flutter|bloc_signals_riverpod - 🐙 GitHub Repository: RandalSchwartz/BlocSignal
Top comments (0)