effidevFlutter · Edge de Cloudflare · Optimización de costes en la nube

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

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, showModalBottomSheet o rootNavigator: true se salen del árbol de overrides del ProviderScope y terminan leyendo una instancia distinta a la esperada.
  • El codegen de Riverpod (@riverpod + build_runner) genera fricción de colaboración. Los archivos .g.dart aumentan 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 riverpod 3.3.2, riverpod_generator 4.0.4, flutter_bloc 9.1.1, bloc_test 10.0.0 y provider 6.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:

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:

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:

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:

  1. Reemplazo del wrapper raíz: cambiar runApp(MultiProvider(providers: [...], child: MyApp())) por runApp(ProviderScope(child: MyApp())). En este punto, el riesgo es menor si se envuelve el ChangeNotifierProvider existente en la capa de compatibilidad de Riverpod y se opera en paralelo.
  2. Conversión del tipo de widget: para pasar de context.watch<T>() / context.read<T>() a ref.watch(xProvider) / ref.read(xProvider), hace falta convertir StatelessWidget/StatefulWidget en ConsumerWidget/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.
  3. Rediseño del scope: las estructuras anidadas de MultiProvider (scope por tab, scope por ítem de lista) hay que rediseñarlas combinando .family + .autoDispose de 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.
  4. Reescritura de tests: los tests que envolvían el widget con ChangeNotifierProvider<T>.value hay que reenvolverlos con ProviderScope(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ún testProviderScope() reduce bastante el trabajo repetitivo.
  5. Transición gradual: en vez de una reescritura big bang, es más manejable en la práctica ir pantalla por pantalla, bajo un ProviderScope raíz común, usando Riverpod para las pantallas nuevas y dejando que las que aún no se migraron sigan con provider durante 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:

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

Casos donde Bloc probablemente sea la opción correcta

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.