Riverpod vs Bloc, para no arrepentirte en producción: guía práctica de decisión según el tamaño del equipo

Cuando toca elegir una librería de gestión de estado para Flutter, el material que suele circular es del tipo “Riverpod tiene buena seguridad en tiempo de compilación, Bloc es más fácil de testear porque está basado en eventos”: una tabla comparativa de funcionalidades. No es que esté mal, pero lo que realmente atormenta a un equipo en producción no es esa lista de características, sino el costo operativo que solo se manifiesta seis meses o un año después. Este artículo no busca dictaminar cuál de las dos librerías es superior. En su lugar, muestra primero los problemas que cada librería provoca de verdad en el día a día, y ofrece criterios para decidir cuándo elegir una u otra —y cuándo vale la pena migrar— según el tamaño del equipo y las características del dominio.
Resumen clave
- El verdadero riesgo de Riverpod no es la falta de funcionalidades, sino el stale state provocado por errores de scope en los providers. Rutas como
showDialog,showModalBottomSheetorootNavigator: truese salen del árbol de overrides delProviderScopey terminan leyendo una instancia distinta a la esperada.- El codegen de Riverpod (
@riverpod+ build_runner) genera fricción de colaboración. Los archivos.g.dartaumentan el ruido en los diffs, y las personas recién incorporadas suelen encontrarse con un build en rojo desde el primer día.- El verdadero riesgo de Bloc es la explosión de clases de eventos (event explosion). A medida que los eventos se acumulan como si fueran el lenguaje del dominio, es habitual que desarrolladores nuevos reutilicen eventos existentes con un significado distinto, mezclando efectos secundarios que no deberían estar juntos.
- Para un MVP con un equipo de 1 a 3 personas, la fricción de Riverpod es menor; para organizaciones grandes donde varios squads intercambian estado como si fuera un contrato, o para dominios financieros/e-commerce que necesitan event sourcing, la estructura explícita de Bloc resulta más ventajosa.
- A julio de 2026, las versiones estables son
riverpod3.3.2,riverpod_generator4.0.4,flutter_bloc9.1.1,bloc_test10.0.0 yprovider6.1.5+1 (verificado en pub.dev).
Por qué la tabla comparativa de funcionalidades es la pregunta equivocada
Tanto Riverpod como Bloc obtienen buena puntuación en ítems como “testeable”, “soporta DI” o “seguridad en tiempo de compilación”. Y en efecto, ambas librerías funcionan bien en producción. El problema es que esa tabla comparativa solo muestra la curva de aprendizaje en el momento de adoptarla, pero no dice nada sobre qué genera fricción cuando el equipo crece y el código se acumula. En la práctica, los dolores que se repiten en los issue trackers y en la comunidad se dividen en solo dos ejes: en Riverpod, “el estado se desincroniza por un mal manejo del scope”; en Bloc, “a medida que los eventos se multiplican, el equipo pierde el control sobre su significado”.
Dónde revienta Riverpod en producción, en la práctica
Stale state por errores de scope en los providers
El modelo de scope de Riverpod es potente, pero tiene puntos que traicionan la intuición. Empecemos por un patrón de error habitual.
// Inyecta en el scope el orderId válido solo para la pantalla de detalle del pedido
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(),
);
}
}
Hasta aquí todo normal. El problema aparece cuando dentro de _OrderDetailBody se llama a showDialog(context: context, ...) o a showModalBottomSheet. Estos widgets, en su mayoría, se insertan en el Overlay del Navigator raíz. Es decir, se renderizan fuera del subárbol del ProviderScope, así que si dentro del diálogo se llama a ref.watch(currentOrderIdProvider), se lee el valor global por defecto (o el que quedó de la pantalla anterior), sin aplicar el override. Así se produce un bug muy difícil de reproducir: la pantalla muestra el pedido A, pero el diálogo muestra los datos del pedido B.
La segunda causa habitual es el mal uso de autoDispose. Si por costumbre —siguiendo un tutorial— se le agrega .autoDispose a todos los providers, en cuanto desaparece el último listener durante una transición de pantalla, el provider se descarta de inmediato. Cuando ese autoDispose alcanza también a un estado que debería sobrevivir a través de varias pantallas —como la sesión del usuario—, al volver atrás y regresar parece que el estado de login o el contenido del carrito se hubiera reseteado. En realidad no es un “valor obsoleto”, sino un “valor inicial recreado”, pero desde la perspectiva del usuario ambos se sienten igual de stale.
El principio de mitigación es claro:
- Usar
.autoDisposesolo en estado temporal exclusivo de una pantalla; para el estado de sesión o de alcance de app, llamar explícitamente aref.keepAlive()o directamente no agregarle autoDispose. - En widgets que usan diálogos, bottom sheets o un Navigator separado, leer con antelación el valor necesario en el momento del build del
ConsumerWidgety pasarlo como parámetro, o bien hacer el override en elProviderScoperaíz para que cualquier árbol que lo lea vea la misma instancia. - Añadir a la checklist de code review un ítem que pregunte explícitamente “hasta dónde llega realmente el subárbol que envuelve este override”.
La fricción de colaboración y el ruido de diffs que genera build_runner
Al usar riverpod_generator, cada clase o función anotada con @riverpod necesita un part 'x.g.dart';, y para que el IDE se mantenga sin líneas rojas hace falta tener corriendo en segundo plano dart run build_runner watch --delete-conflicting-outputs cada vez que se guarda el código. Esto genera fricción a nivel de equipo en tres frentes:
- Fricción de onboarding: cuando alguien nuevo clona el repo y solo ejecuta
flutter pub get, el proyecto queda lleno de errores rojos. Es fácil que la ejecución debuild_runnerse omita en la documentación de onboarding, y cuando eso pasa se repite la pregunta de “¿por qué no compila si no toqué nada?”. - Ruido en los diffs: en equipos que commitean los
.g.dartal repositorio, con solo cambiar la firma de un provider se modifican decenas o cientos de líneas de archivos generados. Esto le cuesta carga cognitiva al revisor a la hora de distinguir un cambio de lógica real de un cambio de código generado. - Aumento del tiempo de CI: en equipos que no commitean los
.g.dart, hay que volver a correr el codegen en cada pipeline de CI, y a medida que crece el tamaño del monorepo, el tiempo de build incremental se hace notorio.
Sí existe una forma de mitigarlo. Riverpod se puede usar perfectamente sin codegen, con la sintaxis pura de Provider/NotifierProvider/StateNotifierProvider. Si el equipo aún no tiene la madurez necesaria para aprovechar las ventajas del codegen (inferencia automática de .family/.autoDispose, menos boilerplate), empezar en estilo non-codegen y migrar una vez que las convenciones estén asentadas es la opción más realista para reducir la fricción de colaboración.
La trampa habitual de Bloc: la explosión de clases de eventos (event explosion)
Bloc impone un flujo explícito de “evento → Bloc → estado”. Esta explicitud es una ventaja al principio, pero a medida que se agregan funcionalidades, las clases de eventos se multiplican de forma exponencial.
abstract class CartEvent extends Equatable {
const CartEvent();
@override
List<Object?> get props => [];
}
class CartItemAdded extends CartEvent { /* ... */ }
class CartItemAddedFromRecommendation extends CartEvent { /* ... */ } // solo difiere en campos de analítica
class CartItemQuantityIncreased extends CartEvent { /* ... */ }
class CartItemQuantityDecreased extends CartEvent { /* ... */ }
class CartItemQuantityChangedManually extends CartEvent { /* ... */ } // disparado por un timer de debounce
class CartCouponApplied extends CartEvent { /* ... */ }
class CartCouponAppliedFromDeepLink extends CartEvent { /* ... */ } // efecto secundario distinto
Aquí es donde ocurre el accidente típico. Un desarrollador nuevo agrega la funcionalidad “cambiar en bloque la cantidad de todos los ítems del carrito” y, para eso, reutiliza el handler existente de CartItemQuantityChangedManually. Por el nombre parece un “evento que cambia la cantidad” sin mayor problema, pero el handler real llevaba incorporado un timer de debounce y un logging de analítica que solo tenían sentido para la entrada manual. El resultado: al hacer el cambio en bloque se crean tantos timers de debounce como ítems haya al mismo tiempo, y el servidor de analítica registra N veces un evento de “edición manual” que el usuario jamás realizó. El nombre del evento parecía lenguaje de dominio, pero en realidad estaba ocultando un efecto secundario acoplado accidentalmente al handler.
Este problema no ocurre por baja calidad de código, sino porque el naming de los eventos y la responsabilidad del handler no están separados. Las mitigaciones prácticas que puede aplicar un equipo son:
- Que la clase de evento contenga únicamente y de forma pura “qué ocurrió”, y que efectos secundarios como el debounce o el logging de analítica se separen en una capa intermedia (los transformadores de eventos de
bloc_concurrency, o la capa de repositorio). - Si la funcionalidad es simplemente “actualizar un único valor” y el historial de eventos en sí no tiene valor de dominio, usar directamente
Cubiten lugar deBloc, que no requiere clases de eventos. No tiene sentido forzar una capa de eventos en un estado de pantalla que no necesita audit log ni replay. - Incluir en la plantilla de PR un checkbox que pregunte “por qué hace falta un evento nuevo en vez de reutilizar este otro”, para que la reutilización de eventos quede siempre a la vista del revisor.
- Aprovechar activamente los transformadores
sequential(),droppable()yrestartable()del paquetebloc_concurrency, para que “qué pasa cuando el mismo evento se dispara varias veces seguidas” quede definido en la configuración del transformador y no en la definición del evento.
Tabla de criterios de elección según tamaño de equipo y dominio
| Equipo/dominio | Recomendación | Motivo |
|---|---|---|
| MVP de startup con 1 a 3 personas | Riverpod (non-codegen) | Permite pivotar rápido sin el boilerplate de crear el trío Event/State/Bloc por cada funcionalidad. El codegen se puede sumar más adelante sin problema |
| Equipo en crecimiento de 4 a 15 personas | Depende de la capacidad de documentar convenciones | El contrato explícito de Bloc favorece el onboarding, pero requiere capacidad real para imponer, mediante documentación y revisión, una gobernanza de eventos (reglas contra la reutilización). Si esa capacidad falta, Riverpod + patrón Notifier tiene menor costo de mantenimiento |
| Enterprise a gran escala (múltiples squads) | Bloc | Las clases Event/State funcionan como un contrato entre squads, y los cambios de interfaz quedan claramente visibles en el PR review. El grafo de dependencias implícito de Riverpod tiende a esconder el acoplamiento entre squads si no se fuerza con linting |
| Finanzas/e-commerce que requiere event sourcing | Bloc | Los eventos de dominio se corresponden de forma natural con los eventos de Bloc, y el stream de eventos se puede aplicar directamente a un event store o reutilizar como audit log. Riverpod es un paradigma centrado en el estado, así que el log de eventos hay que agregarlo por separado |
Esta tabla no debe leerse como “con este tamaño, esta librería sí o sí”, sino como una herramienta para preguntarse qué tipo de fricción tiene el equipo capacidad de asumir. Por ejemplo, incluso en una organización grande, si existe un equipo interno de lint/arquitectura lo bastante fuerte como para detectar por análisis estático el mal uso del scope en Riverpod, apostar por Riverpod también puede ser una decisión razonable.
Un caso real de migración: la transición de Provider a Riverpod
La migración de una app que arrancó con el paquete provider (actualmente 6.1.5+1) hacia Riverpod suele seguir, en general, este orden:
- Reemplazo del wrapper raíz: cambiar
runApp(MultiProvider(providers: [...], child: MyApp()))porrunApp(ProviderScope(child: MyApp())). En este punto, el riesgo es menor si se envuelve elChangeNotifierProviderexistente en la capa de compatibilidad de Riverpod y se opera en paralelo. - Conversión del tipo de widget: para pasar de
context.watch<T>()/context.read<T>()aref.watch(xProvider)/ref.read(xProvider), hace falta convertirStatelessWidget/StatefulWidgetenConsumerWidget/ConsumerStatefulWidget. Es un cambio mecánico, pero con un diff grande, así que en la práctica funciona bien dividir los PR por funcionalidad, en tamaños razonables de revisar. - Rediseño del scope: las estructuras anidadas de
MultiProvider(scope por tab, scope por ítem de lista) hay que rediseñarlas combinando.family+.autoDisposede Riverpod. Aquí es donde, en la práctica, más se reporta el problema de stale state por mal uso de autoDispose que se explicó antes. Es habitual el accidente de seguir el tutorial al pie de la letra, aplicar autoDispose incluso al estado de sesión, y terminar reseteando el estado de login. - Reescritura de tests: los tests que envolvían el widget con
ChangeNotifierProvider<T>.valuehay que reenvolverlos conProviderScope(overrides: [...]). En apps con cientos de widget tests, esta es la parte que más líneas ocupa en el diff de la migración. Preparar de antemano un helper comúntestProviderScope()reduce bastante el trabajo repetitivo. - Transición gradual: en vez de una reescritura big bang, es más manejable en la práctica ir pantalla por pantalla, bajo un
ProviderScoperaíz común, usando Riverpod para las pantallas nuevas y dejando que las que aún no se migraron sigan conproviderdurante un tiempo. Como ambas librerías conviven sin conflicto dentro del mismo árbol de widgets, no hace falta forzar terminarlo todo de una vez.
Comparación de la facilidad de testeo: override de providers vs bloc_test
Riverpod reemplaza dependencias pasando overrides a un ProviderContainer.
test('expone el estado error cuando falla la carga de la lista de pedidos', () async {
final container = ProviderContainer(
overrides: [
orderRepositoryProvider.overrideWithValue(
FakeOrderRepository()..throwsOnFetch = true,
),
],
);
addTearDown(container.dispose);
await expectLater(
container.read(orderListProvider.future),
throwsA(isException),
);
});
Bloc verifica de forma declarativa la secuencia evento-estado con blocTest del paquete bloc_test.
blocTest<OrderBloc, OrderState>(
'emite OrderError cuando falla el fetch',
build: () {
when(() => repository.fetchOrders()).thenThrow(Exception('network'));
return OrderBloc(repository: repository);
},
act: (bloc) => bloc.add(OrderFetched()),
expect: () => [OrderLoading(), isA<OrderError>()],
);
En la práctica, la diferencia entre ambos enfoques es la siguiente:
- Riverpod: como el propio provider ya funciona como un contenedor de DI, se puede reemplazar un nodo específico del grafo solo con
overrideWithValue/overrideWith, sin necesidad de una librería de mocking aparte. Sin embargo, al manejar en los tests la combinación.family+.autoDispose, es fácil olvidarse decontainer.dispose()y recibir avisos de fuga de recursos, o no lograr apuntar a la instancia exacta del provider que se quiere overridear y terminar con errores del tipo “provider not found”. - Bloc: la estructura
act/expect/verifydeblocTestes fácil de leer de un vistazo para QA o para quien revisa el código: “qué evento entra, qué estado sale”. Sin embargo, como Bloc en sí no ofrece un mecanismo de DI, la capa de repositorio hay que combinarla con una librería de mocking aparte, comomocktail, junto con inyección por constructor (oget_it). Es decir, “testear la secuencia de eventos” y “mockear dependencias” quedan separados en herramientas distintas. - En definitiva, hay que tener presente que Riverpod arrastra el concepto de scope también a los tests, así que los mismos errores de scope que se sufren en producción pueden repetirse igual en los tests; y que Bloc facilita la verificación de secuencias de eventos, pero exige montar aparte su propia infraestructura de inyección de dependencias.
Conclusión: checklist de decisión según el tamaño del código base y la madurez del equipo
Cuantos más “sí” haya en los siguientes ítems, más preparado está el equipo para asumir la fricción de esa librería.
Casos donde Riverpod probablemente sea la opción correcta
- Es un equipo pequeño de 3 personas o menos, o está en una etapa temprana de MVP donde las specs de funcionalidad cambian con frecuencia.
- Hay una persona responsable de documentar las reglas de scope de
ProviderScope/autoDisposey hacerlas cumplir en code review. - El equipo puede decidir de forma autónoma si adopta codegen o no, y tiene capacidad para mantener la documentación de onboarding de
build_runner. - Hay pocas pantallas que usen el Overlay del Navigator (diálogos, bottom sheets), o ya existe una guía de scope para ese patrón.
Casos donde Bloc probablemente sea la opción correcta
- Varios squads tocan el mismo código base al mismo tiempo, y se quiere que el contrato de cambios de estado quede explícito en el PR review.
- Como en finanzas o e-commerce, los eventos en sí están ligados a requisitos de audit log o replay.
- Hay una incorporación frecuente de personal nuevo, y ya existe (o se planea crear) un proceso para forzar, mediante reglas de review, la pregunta de “¿está bien reutilizar este evento?”.
- Existe la disciplina de diferenciar: usar
Cubitpara pantallas de simple actualización de valores, y reservarBlocsolo para donde el historial de eventos tenga valor real de dominio.
Sea cual sea la opción elegida, lo que realmente define el éxito o el fracaso en producción no es tanto la librería en sí, sino si el equipo es capaz de crear por su cuenta la disciplina que esa librería no impone. Riverpod exige que el equipo establezca su propia disciplina de scope; Bloc, su propia disciplina de gobernanza de eventos. Elegir según qué lado tiene la capacidad y la voluntad de establecer esa disciplina es un método de decisión mucho más certero que cualquier tabla comparativa de funcionalidades.