Gestión de estado en Flutter con Riverpod: por qué reemplazamos Provider en proyectos nuevos
Durante un tiempo, Provider fue la respuesta por defecto cuando alguien nos preguntaba cómo manejar estado en un proyecto Flutter nuevo. Es la solución que recomienda buena parte de la documentación histórica del ecosistema, se apoya en InheritedWidget que ya conocemos de Flutter puro, y para pantallas simples funciona sin fricción. El problema no es que Provider esté roto: es que a medida que un proyecto crece, empieza a exigir cosas que Provider no puede dar porque su diseño depende del árbol de widgets para resolver dependencias, y esa dependencia se paga en testing, en errores de runtime que el compilador no detecta, y en acoplamiento que se vuelve difícil de deshacer.
Este artículo explica los problemas concretos que nos llevaron a adoptar Riverpod como estándar en proyectos nuevos, no como preferencia estética sino como respuesta a fallos que veíamos repetirse.
El problema de fondo: Provider depende de BuildContext
Provider expone sus dependencias a través del árbol de widgets. Para leer un valor, un widget necesita un BuildContext que esté por debajo del Provider correspondiente en el árbol:
class CarritoScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final carrito = context.watch<CarritoProvider>();
return Text('${carrito.total}');
}
}
Esto parece razonable hasta que se necesita leer ese mismo estado fuera de un widget: en un callback asíncrono después de un await, en un método de un servicio que no tiene acceso al árbol, o en un test unitario que no quiere montar un MaterialApp completo solo para instanciar un provider. La solución habitual con Provider es pasar el BuildContext a capas que no deberían conocerlo, o capturarlo antes de un await y usarlo después, lo cual genera el error clásico de "Looking up a deactivated widget's ancestor is unsafe" cuando el widget se desmontó mientras la operación asíncrona estaba en vuelo.
Future<void> confirmarCompra(BuildContext context) async {
final carrito = context.read<CarritoProvider>();
await procesarPago(carrito.total);
// si el widget que llamó a esto ya no está montado, esto explota:
context.read<NavegacionProvider>().irAConfirmacion();
}
El error no aparece en tiempo de compilación. Aparece en producción, cuando un usuario navega rápido o la conexión es lenta y el await tarda lo suficiente como para que la pantalla ya no exista cuando la respuesta llega. Es exactamente el tipo de fallo intermitente que cuesta reproducir en desarrollo y que termina reportado como "a veces se cae la app al pagar" sin más contexto.
Dependencias implícitas: lo que un provider necesita no se ve en su firma
Con Provider, un ChangeNotifierProvider normalmente depende de otros providers que están más arriba en el árbol, resueltos con ProxyProvider o leyendo del contexto en el constructor. Esa dependencia no aparece en ningún tipo: para saber qué necesita un provider hay que leer su implementación completa, y para saber si dos providers están en el orden correcto hay que revisar el árbol de widgets donde se registran, normalmente en un MultiProvider al inicio de la app.
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProxyProvider<AuthProvider, CarritoProvider>(
create: (_) => CarritoProvider(),
update: (_, auth, carrito) => carrito!..actualizarUsuario(auth.userId),
),
],
child: MyApp(),
)
Si alguien invierte el orden de esos dos providers en el MultiProvider, el error aparece en tiempo de ejecución, normalmente como un ProviderNotFoundException al intentar resolver una dependencia que todavía no existe en el árbol en ese punto. No hay forma de que el analizador de Dart detecte ese problema antes de ejecutar la app, porque desde el punto de vista de tipos, ChangeNotifierProxyProvider no expresa que necesita a AuthProvider disponible más arriba: eso vive únicamente en el orden de una lista.
Riverpod invierte esto. Un provider declara sus dependencias llamando a otros providers directamente, sin pasar por el árbol de widgets, y el grafo de dependencias se resuelve una sola vez, en el propio código, no en el orden de registro:
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
final carritoProvider = NotifierProvider<CarritoNotifier, CarritoState>(
CarritoNotifier.new,
);
class CarritoNotifier extends Notifier<CarritoState> {
@override
CarritoState build() {
final auth = ref.watch(authProvider);
return CarritoState.inicial(userId: auth.userId);
}
}
carritoProvider declara explícitamente, en su propio build, que depende de authProvider. No hace falta registrar nada en un orden particular ni envolver la app en un árbol de proveedores anidados: Riverpod resuelve la dependencia la primera vez que algo lee carritoProvider, sin importar en qué archivo o en qué orden se definieron ambos providers. Y si authProvider cambia, carritoProvider se reconstruye automáticamente porque ref.watch establece esa relación de forma explícita, con tipos que el analizador sí puede verificar.
Testing: providers sin árbol de widgets
La diferencia que más pesa en la práctica diaria es la de testing. Probar un ChangeNotifierProvider de Provider en aislamiento, sin levantar un widget tree, es incómodo porque el patrón está pensado para vivir dentro de un BuildContext. Lo habitual es envolver el test en un MaterialApp con un Builder, montar el widget con flutter_test, y disparar la lógica a través de interacciones de UI simuladas, incluso cuando lo único que se quiere probar es la lógica del provider, no ningún widget.
testWidgets('el carrito calcula el total correctamente', (tester) async {
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => CarritoProvider(),
child: MaterialApp(home: Builder(builder: (context) {
return ElevatedButton(
onPressed: () => context.read<CarritoProvider>().agregar(producto),
child: Text('agregar'),
);
})),
),
);
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// ...
});
Es un test de widget para probar lo que en realidad es lógica de negocio pura. Con Riverpod, un Notifier es una clase Dart normal que se puede instanciar, testear y descartar sin ningún widget de por medio, usando un ProviderContainer:
test('el carrito calcula el total correctamente', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(carritoProvider.notifier).agregar(producto);
expect(container.read(carritoProvider).total, 25000);
});
Esto es un test unitario puro, corre en milisegundos, no depende de flutter_test ni de montar nada visual, y se puede ejecutar en un paquete Dart plano sin dependencia del SDK de Flutter si la lógica está bien separada. Cuando un equipo migra un proyecto grande a Riverpod, este es normalmente el cambio que más impacto tiene en la velocidad de la suite de tests: providers que antes exigían un widget test completo pasan a ser tests unitarios directos sobre la clase Notifier.
Migración paso a paso de un provider real
Tomamos un ChangeNotifierProvider típico de autenticación y lo llevamos a Riverpod para mostrar el cambio completo, no solo el patrón abstracto.
Antes, con Provider:
class AuthProvider extends ChangeNotifier {
User? _user;
bool _cargando = false;
User? get user => _user;
bool get cargando => _cargando;
Future<void> login(String email, String password) async {
_cargando = true;
notifyListeners();
try {
_user = await authRepository.login(email, password);
} finally {
_cargando = false;
notifyListeners();
}
}
void logout() {
_user = null;
notifyListeners();
}
}
El uso en la UI necesita context.watch para reconstruir y context.read dentro de callbacks:
class LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cargando = context.watch<AuthProvider>().cargando;
return ElevatedButton(
onPressed: cargando
? null
: () => context.read<AuthProvider>().login(email, password),
child: cargando ? CircularProgressIndicator() : Text('Entrar'),
);
}
}
Después, con Riverpod, el estado se modela explícitamente en lugar de vivir como dos variables sueltas, y el Notifier no depende de notifyListeners manual:
sealed class AuthState {
const AuthState();
}
class AuthInicial extends AuthState {}
class AuthCargando extends AuthState {}
class AuthAutenticado extends AuthState {
final User user;
const AuthAutenticado(this.user);
}
class AuthError extends AuthState {
final String mensaje;
const AuthError(this.mensaje);
}
class AuthNotifier extends Notifier<AuthState> {
@override
AuthState build() => AuthInicial();
Future<void> login(String email, String password) async {
state = AuthCargando();
try {
final user = await ref.read(authRepositoryProvider).login(email, password);
state = AuthAutenticado(user);
} catch (e) {
state = AuthError(e.toString());
}
}
void logout() => state = AuthInicial();
}
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
Y en la UI, con ConsumerWidget en lugar de StatelessWidget:
class LoginButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final estado = ref.watch(authProvider);
final cargando = estado is AuthCargando;
return ElevatedButton(
onPressed: cargando
? null
: () => ref.read(authProvider.notifier).login(email, password),
child: cargando ? CircularProgressIndicator() : Text('Entrar'),
);
}
}
El cambio no es solo mecánico. Modelar el estado como una jerarquía sellada (AuthInicial, AuthCargando, AuthAutenticado, AuthError) en vez de dos booleanos y un campo nullable obliga a manejar cada caso explícitamente en la UI, y el analizador de Dart marca error si un switch sobre AuthState deja un caso sin cubrir. Con Provider, ese mismo estado casi siempre termina representado como combinaciones de cargando, user y algún campo de error opcional, que permiten estados imposibles como cargando == true y user != null al mismo tiempo, sin que nada en el tipo lo impida.
Dónde Provider sigue siendo suficiente
No migramos todo por migrar. Para una app pequeña, con dos o tres pantallas y estado que nunca cruza más de un nivel de widgets, Provider sigue siendo una opción razonable: la curva de aprendizaje es menor y el paquete es más liviano en dependencias. El cambio a Riverpod se justifica cuando aparece alguna de estas señales: el estado necesita leerse fuera del árbol de widgets, hay dependencias entre providers que se vuelven difíciles de ordenar, o la suite de tests empieza a depender de montar widgets solo para probar lógica que no tiene nada de visual.
En proyectos nuevos con Flutter, esas señales casi siempre aparecen antes de la primera entrega, así que Riverpod es hoy nuestro punto de partida por defecto y no una migración que postergamos hasta que el dolor se vuelve evidente.