# State Management Flutter : Riverpod vs BLoC - Guide Comparatif Complet > Comparaison approfondie entre Riverpod et BLoC pour la gestion d'état Flutter. Architecture, performances, testabilité et cas d'usage pour choisir la meilleure solution. - Published: 2026-02-14 - Updated: 2026-03-28 - Author: SharpSkill - Tags: flutter, riverpod, bloc, state management, dart - Reading time: 12 min --- La gestion d'état représente un défi central dans le développement Flutter. Riverpod et BLoC dominent l'écosystème, chacun proposant une philosophie distincte. Ce guide compare ces deux solutions à travers des implémentations concrètes pour éclairer le choix selon les besoins du projet. > **Prérequis** > > Ce guide suppose une familiarité avec Flutter et les bases du state management. Les exemples utilisent Riverpod 2.x et flutter_bloc 8.x, les versions stables actuelles. ## Philosophies fondamentales des deux approches Riverpod et BLoC répondent au même problème avec des paradigmes opposés. Comprendre ces différences conceptuelles permet de choisir l'outil adapté à chaque contexte. **Riverpod** adopte une approche déclarative et réactive. Les providers définissent des sources de données que les widgets observent. Le framework gère automatiquement le cycle de vie, la mise en cache et les dépendances entre providers. **BLoC** (Business Logic Component) impose une architecture événementielle stricte. Les composants émettent des événements, le Bloc les traite et produit de nouveaux états. Cette séparation explicite facilite le suivi du flux de données. ```dart // riverpod_philosophy.dart // Riverpod : déclaration simple, le framework gère le reste final counterProvider = StateProvider((ref) => 0); // Utilisation dans un widget class CounterWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Lecture réactive : rebuild automatique si la valeur change final count = ref.watch(counterProvider); return Text('$count'); } } ``` ```dart // bloc_philosophy.dart // BLoC : séparation explicite événements/états abstract class CounterEvent {} class IncrementPressed extends CounterEvent {} class CounterBloc extends Bloc { CounterBloc() : super(0) { // Chaque événement a son handler dédié on((event, emit) => emit(state + 1)); } } // Utilisation dans un widget class CounterWidget extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, count) => Text('$count'), ); } } ``` Le choix entre ces approches dépend des préférences d'équipe et des contraintes du projet. ## Configuration et mise en place initiale La configuration initiale révèle les différences d'ergonomie entre les deux solutions. Riverpod privilégie la simplicité, BLoC offre plus de structure. ### Installation de Riverpod Riverpod nécessite un seul package et un wrapper à la racine de l'application. La génération de code optionnelle améliore la productivité. ```dart // main.dart // Configuration Riverpod : wrapper unique à la racine import 'package:flutter_riverpod/flutter_riverpod.dart'; void main() { runApp( // ProviderScope englobe toute l'application const ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: HomeScreen(), ); } } ``` ### Installation de BLoC BLoC demande plusieurs packages et une configuration plus élaborée avec des BlocProviders pour chaque Bloc utilisé. ```dart // main.dart // Configuration BLoC : providers explicites pour chaque Bloc import 'package:flutter_bloc/flutter_bloc.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { // MultiBlocProvider pour plusieurs Blocs return MultiBlocProvider( providers: [ BlocProvider(create: (_) => AuthBloc()), BlocProvider(create: (_) => ThemeBloc()), ], child: MaterialApp( home: HomeScreen(), ), ); } } ``` La configuration BLoC exige plus de code initial mais rend les dépendances explicites dès le départ. ## Gestion d'état simple : compteur et toggles Les cas simples illustrent l'ergonomie quotidienne de chaque solution. Riverpod excelle dans la concision, BLoC maintient sa structure événementielle. ### Compteur avec Riverpod ```dart // counter_riverpod.dart // StateProvider : état simple sans logique complexe final counterProvider = StateProvider((ref) => 0); class CounterScreen extends ConsumerWidget { const CounterScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // watch pour la valeur réactive final count = ref.watch(counterProvider); return Scaffold( body: Center(child: Text('Count: $count')), floatingActionButton: FloatingActionButton( // read pour les actions (pas de rebuild) onPressed: () => ref.read(counterProvider.notifier).state++, child: const Icon(Icons.add), ), ); } } ``` ### Compteur avec BLoC ```dart // counter_bloc.dart // Événements typés pour chaque action possible sealed class CounterEvent {} class CounterIncremented extends CounterEvent {} class CounterDecremented extends CounterEvent {} class CounterReset extends CounterEvent {} // Bloc avec handlers pour chaque événement class CounterBloc extends Bloc { CounterBloc() : super(0) { on((event, emit) => emit(state + 1)); on((event, emit) => emit(state - 1)); on((event, emit) => emit(0)); } } class CounterScreen extends StatelessWidget { const CounterScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: BlocBuilder( builder: (context, count) => Text('Count: $count'), ), ), floatingActionButton: FloatingActionButton( // Dispatch d'événement pour modifier l'état onPressed: () => context.read().add(CounterIncremented()), child: const Icon(Icons.add), ), ); } } ``` Pour les cas simples, Riverpod réduit significativement le boilerplate. BLoC devient pertinent quand la logique se complexifie. > **StateProvider vs StateNotifierProvider** > > StateProvider convient aux valeurs primitives simples. Pour des objets complexes ou une logique métier, StateNotifierProvider ou NotifierProvider offrent plus de contrôle. ## Gestion d'état asynchrone : appels API Les opérations asynchrones révèlent la puissance de chaque solution. La gestion des états loading/error/data constitue un enjeu majeur. ### Données asynchrones avec Riverpod ```dart // async_riverpod.dart // FutureProvider : gestion automatique loading/error/data final usersProvider = FutureProvider.autoDispose>((ref) async { final repository = ref.watch(userRepositoryProvider); // autoDispose libère les ressources quand le provider n'est plus utilisé return repository.fetchUsers(); }); class UsersScreen extends ConsumerWidget { const UsersScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final usersAsync = ref.watch(usersProvider); // when gère les 3 états possibles return usersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Erreur: $error'), ElevatedButton( // invalidate force le rechargement onPressed: () => ref.invalidate(usersProvider), child: const Text('Réessayer'), ), ], ), ), data: (users) => ListView.builder( itemCount: users.length, itemBuilder: (context, index) => UserTile(user: users[index]), ), ); } } ``` ### Données asynchrones avec BLoC ```dart // async_bloc.dart // États explicites pour chaque phase du chargement sealed class UsersState {} class UsersInitial extends UsersState {} class UsersLoading extends UsersState {} class UsersLoaded extends UsersState { final List users; UsersLoaded(this.users); } class UsersError extends UsersState { final String message; UsersError(this.message); } // Événements pour déclencher les actions sealed class UsersEvent {} class UsersFetchRequested extends UsersEvent {} class UsersRefreshRequested extends UsersEvent {} class UsersBloc extends Bloc { final UserRepository _repository; UsersBloc(this._repository) : super(UsersInitial()) { on(_onFetchRequested); on(_onRefreshRequested); } Future _onFetchRequested( UsersFetchRequested event, Emitter emit, ) async { emit(UsersLoading()); try { final users = await _repository.fetchUsers(); emit(UsersLoaded(users)); } catch (e) { emit(UsersError(e.toString())); } } Future _onRefreshRequested( UsersRefreshRequested event, Emitter emit, ) async { // Garde l'état actuel pendant le refresh final currentState = state; try { final users = await _repository.fetchUsers(); emit(UsersLoaded(users)); } catch (e) { // Restaure l'état précédent en cas d'erreur if (currentState is UsersLoaded) { emit(currentState); } else { emit(UsersError(e.toString())); } } } } ``` ```dart // users_screen_bloc.dart // Widget avec pattern matching sur les états class UsersScreen extends StatelessWidget { const UsersScreen({super.key}); @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { return switch (state) { UsersInitial() => const Center( child: ElevatedButton( onPressed: _fetchUsers, child: Text('Charger'), ), ), UsersLoading() => const Center(child: CircularProgressIndicator()), UsersError(:final message) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Erreur: $message'), ElevatedButton( onPressed: () => context .read() .add(UsersFetchRequested()), child: const Text('Réessayer'), ), ], ), ), UsersLoaded(:final users) => ListView.builder( itemCount: users.length, itemBuilder: (context, index) => UserTile(user: users[index]), ), }; }, ); } void _fetchUsers(BuildContext context) { context.read().add(UsersFetchRequested()); } } ``` BLoC offre un contrôle granulaire sur chaque transition d'état. Riverpod automatise davantage avec AsyncValue. ## Dépendances entre états : composition et injection Les applications réelles impliquent des états interdépendants. La gestion de ces dépendances différencie significativement les deux approches. ### Composition avec Riverpod ```dart // composition_riverpod.dart // Provider de base : configuration final apiClientProvider = Provider((ref) { final baseUrl = ref.watch(environmentProvider).apiUrl; return ApiClient(baseUrl: baseUrl); }); // Provider dépendant : repository final productRepositoryProvider = Provider((ref) { // Injection automatique du client final client = ref.watch(apiClientProvider); return ProductRepository(client); }); // Provider avec paramètre : produit par ID final productProvider = FutureProvider.autoDispose.family( (ref, productId) async { final repository = ref.watch(productRepositoryProvider); return repository.getProduct(productId); }, ); // Provider dérivé : produits filtrés final filteredProductsProvider = Provider>((ref) { final products = ref.watch(productsProvider).valueOrNull ?? []; final filter = ref.watch(productFilterProvider); return products.where((p) => p.category == filter.category).toList(); }); // Utilisation avec paramètre class ProductDetailScreen extends ConsumerWidget { final String productId; const ProductDetailScreen({super.key, required this.productId}); @override Widget build(BuildContext context, WidgetRef ref) { // family permet de passer des paramètres final productAsync = ref.watch(productProvider(productId)); return productAsync.when( loading: () => const ProductSkeleton(), error: (e, _) => ErrorWidget(error: e), data: (product) => ProductDetails(product: product), ); } } ``` ### Composition avec BLoC ```dart // composition_bloc.dart // Repository injecté dans le Bloc class ProductBloc extends Bloc { final ProductRepository _repository; final CartBloc _cartBloc; late final StreamSubscription _cartSubscription; ProductBloc({ required ProductRepository repository, required CartBloc cartBloc, }) : _repository = repository, _cartBloc = cartBloc, super(ProductInitial()) { on(_onFetchRequested); on(_onAddedToCart); // Écoute des changements du panier _cartSubscription = _cartBloc.stream.listen((cartState) { // Réagit aux changements du panier if (cartState is CartUpdated) { add(ProductCartSyncRequested(cartState.items)); } }); } Future _onFetchRequested( ProductFetchRequested event, Emitter emit, ) async { emit(ProductLoading()); try { final product = await _repository.getProduct(event.productId); // Vérifie si le produit est dans le panier final isInCart = _cartBloc.state.contains(product.id); emit(ProductLoaded(product, isInCart: isInCart)); } catch (e) { emit(ProductError(e.toString())); } } @override Future close() { _cartSubscription.cancel(); return super.close(); } } // Configuration avec injection de dépendances class ProductsPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => ProductBloc( repository: context.read(), cartBloc: context.read(), )..add(ProductFetchRequested()), child: const ProductsView(), ); } } ``` Riverpod gère les dépendances de manière déclarative. BLoC requiert une gestion manuelle des subscriptions entre Blocs. ## Testabilité et mocking Les tests constituent un critère décisif pour les projets professionnels. Les deux solutions excellent dans ce domaine avec des approches différentes. ### Tests avec Riverpod ```dart // test_riverpod.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockUserRepository extends Mock implements UserRepository {} void main() { group('UserProvider Tests', () { late MockUserRepository mockRepository; late ProviderContainer container; setUp(() { mockRepository = MockUserRepository(); // Container isolé avec override container = ProviderContainer( overrides: [ userRepositoryProvider.overrideWithValue(mockRepository), ], ); }); tearDown(() => container.dispose()); test('retourne les utilisateurs depuis le repository', () async { // Arrange final expectedUsers = [User(id: '1', name: 'Test')]; when(() => mockRepository.fetchUsers()) .thenAnswer((_) async => expectedUsers); // Act final users = await container.read(usersProvider.future); // Assert expect(users, expectedUsers); verify(() => mockRepository.fetchUsers()).called(1); }); test('gère les erreurs du repository', () async { when(() => mockRepository.fetchUsers()) .thenThrow(Exception('Network error')); expect( () => container.read(usersProvider.future), throwsException, ); }); }); } ``` ### Tests avec BLoC ```dart // test_bloc.dart import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockUserRepository extends Mock implements UserRepository {} void main() { group('UsersBloc Tests', () { late MockUserRepository mockRepository; setUp(() { mockRepository = MockUserRepository(); }); // blocTest simplifie les tests de séquences d'états blocTest( 'émet [Loading, Loaded] quand fetch réussit', build: () { when(() => mockRepository.fetchUsers()) .thenAnswer((_) async => [User(id: '1', name: 'Test')]); return UsersBloc(mockRepository); }, act: (bloc) => bloc.add(UsersFetchRequested()), expect: () => [ isA(), isA().having( (s) => s.users.length, 'users count', 1, ), ], ); blocTest( 'émet [Loading, Error] quand fetch échoue', build: () { when(() => mockRepository.fetchUsers()) .thenThrow(Exception('Network error')); return UsersBloc(mockRepository); }, act: (bloc) => bloc.add(UsersFetchRequested()), expect: () => [ isA(), isA(), ], ); }); } ``` Le package `bloc_test` offre une syntaxe dédiée pour tester les séquences d'états. Riverpod utilise les patterns de test standard de Flutter. > **Couverture des tests** > > Tester uniquement les cas nominaux ne suffit pas. Les tests doivent couvrir les erreurs réseau, les timeouts, les états edge-case et les transitions d'état inattendues. ## Performances et optimisation des rebuilds Les performances impactent directement l'expérience utilisateur. Les deux solutions proposent des mécanismes d'optimisation distincts. ### Optimisation Riverpod ```dart // perf_riverpod.dart // select pour ne reconstruire que si la valeur ciblée change class UserNameWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Reconstruit uniquement si user.name change final name = ref.watch(userProvider.select((user) => user.name)); return Text(name); } } // Provider avec cache automatique final expensiveComputationProvider = Provider((ref) { final input = ref.watch(inputProvider); // Calcul mis en cache automatiquement return performExpensiveComputation(input); }); // autoDispose pour libérer les ressources inutilisées final searchResultsProvider = FutureProvider.autoDispose .family, String>((ref, query) async { // keepAlive temporaire pendant la saisie final link = ref.keepAlive(); // Timer pour libérer après inactivité final timer = Timer(const Duration(seconds: 30), link.close); ref.onDispose(timer.cancel); return searchProducts(query); }); ``` ### Optimisation BLoC ```dart // perf_bloc.dart // buildWhen limite les rebuilds conditionnellement class UserNameWidget extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( // Reconstruit uniquement si le nom change buildWhen: (previous, current) { if (previous is UserLoaded && current is UserLoaded) { return previous.user.name != current.user.name; } return true; }, builder: (context, state) { if (state is UserLoaded) { return Text(state.user.name); } return const SizedBox.shrink(); }, ); } } // BlocSelector pour extraire une valeur spécifique class UserAvatarWidget extends StatelessWidget { @override Widget build(BuildContext context) { return BlocSelector( // Sélectionne uniquement l'URL de l'avatar selector: (state) => state is UserLoaded ? state.user.avatarUrl : null, builder: (context, avatarUrl) { if (avatarUrl == null) return const DefaultAvatar(); return NetworkImage(avatarUrl); }, ); } } ``` Les deux solutions offrent des optimisations granulaires. Riverpod avec `select`, BLoC avec `buildWhen` et `BlocSelector`. ## Cas d'usage pratique : authentification complète Un système d'authentification illustre les patterns réels de chaque solution. Ce cas combine état persistant, appels API et navigation. ### Authentification avec Riverpod ```dart // auth_riverpod.dart // État d'authentification avec sealed class sealed class AuthState { const AuthState(); } class AuthInitial extends AuthState { const AuthInitial(); } class AuthLoading extends AuthState { const AuthLoading(); } class AuthAuthenticated extends AuthState { final User user; const AuthAuthenticated(this.user); } class AuthUnauthenticated extends AuthState { final String? error; const AuthUnauthenticated([this.error]); } // Notifier pour gérer l'état d'auth class AuthNotifier extends StateNotifier { final AuthRepository _repository; final SecureStorage _storage; AuthNotifier(this._repository, this._storage) : super(const AuthInitial()) { _checkAuthStatus(); } Future _checkAuthStatus() async { final token = await _storage.getToken(); if (token != null) { try { final user = await _repository.getCurrentUser(token); state = AuthAuthenticated(user); } catch (_) { await _storage.deleteToken(); state = const AuthUnauthenticated(); } } else { state = const AuthUnauthenticated(); } } Future login(String email, String password) async { state = const AuthLoading(); try { final result = await _repository.login(email, password); await _storage.saveToken(result.token); state = AuthAuthenticated(result.user); } catch (e) { state = AuthUnauthenticated(e.toString()); } } Future logout() async { await _storage.deleteToken(); state = const AuthUnauthenticated(); } } // Provider avec dépendances injectées final authProvider = StateNotifierProvider((ref) { return AuthNotifier( ref.watch(authRepositoryProvider), ref.watch(secureStorageProvider), ); }); // Redirection basée sur l'état d'auth final routerProvider = Provider((ref) { final authState = ref.watch(authProvider); return GoRouter( redirect: (context, state) { final isAuth = authState is AuthAuthenticated; final isAuthRoute = state.matchedLocation.startsWith('/auth'); if (!isAuth && !isAuthRoute) return '/auth/login'; if (isAuth && isAuthRoute) return '/home'; return null; }, routes: [...], ); }); ``` ### Authentification avec BLoC ```dart // auth_bloc.dart // États exhaustifs pour l'authentification sealed class AuthState { const AuthState(); } class AuthInitial extends AuthState { const AuthInitial(); } class AuthCheckInProgress extends AuthState { const AuthCheckInProgress(); } class AuthLoginInProgress extends AuthState { const AuthLoginInProgress(); } class AuthSuccess extends AuthState { final User user; const AuthSuccess(this.user); } class AuthFailure extends AuthState { final String error; const AuthFailure(this.error); } class AuthLoggedOut extends AuthState { const AuthLoggedOut(); } // Événements d'authentification sealed class AuthEvent { const AuthEvent(); } class AuthCheckRequested extends AuthEvent { const AuthCheckRequested(); } class AuthLoginSubmitted extends AuthEvent { final String email; final String password; const AuthLoginSubmitted(this.email, this.password); } class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); } class AuthBloc extends Bloc { final AuthRepository _repository; final SecureStorage _storage; AuthBloc({ required AuthRepository repository, required SecureStorage storage, }) : _repository = repository, _storage = storage, super(const AuthInitial()) { on(_onCheckRequested); on(_onLoginSubmitted); on(_onLogoutRequested); } Future _onCheckRequested( AuthCheckRequested event, Emitter emit, ) async { emit(const AuthCheckInProgress()); final token = await _storage.getToken(); if (token == null) { emit(const AuthLoggedOut()); return; } try { final user = await _repository.getCurrentUser(token); emit(AuthSuccess(user)); } catch (_) { await _storage.deleteToken(); emit(const AuthLoggedOut()); } } Future _onLoginSubmitted( AuthLoginSubmitted event, Emitter emit, ) async { emit(const AuthLoginInProgress()); try { final result = await _repository.login(event.email, event.password); await _storage.saveToken(result.token); emit(AuthSuccess(result.user)); } catch (e) { emit(AuthFailure(e.toString())); } } Future _onLogoutRequested( AuthLogoutRequested event, Emitter emit, ) async { await _storage.deleteToken(); emit(const AuthLoggedOut()); } } ``` Les deux implémentations gèrent les mêmes fonctionnalités avec des styles différents. BLoC rend chaque transition explicite, Riverpod simplifie la syntaxe. ## Tableau comparatif récapitulatif | Critère | Riverpod | BLoC | |---------|----------|------| | **Courbe d'apprentissage** | Modérée | Plus prononcée | | **Boilerplate** | Minimal | Significatif | | **Typage** | Excellent | Excellent | | **Testabilité** | Excellente | Excellente | | **Traçabilité** | Via DevTools | Events/States explicites | | **Composition** | Automatique | Manuelle | | **Code generation** | Optionnelle | Non requise | | **Taille équipe** | Flexible | Grandes équipes | ## Recommandations selon le contexte Le choix entre Riverpod et BLoC dépend de plusieurs facteurs contextuels. **Choisir Riverpod quand :** - L'équipe privilégie la concision et la productivité - Le projet nécessite une composition flexible d'états - Les développeurs viennent de React ou d'autres frameworks réactifs - La mise en cache automatique représente un avantage significatif **Choisir BLoC quand :** - L'équipe apprécie les patterns stricts et prévisibles - Le projet requiert une traçabilité complète des événements - Les juniors bénéficient d'une architecture imposée - Le debugging nécessite un historique des transitions ## Conclusion Riverpod et BLoC répondent efficacement aux besoins de gestion d'état Flutter. Riverpod excelle en ergonomie et flexibilité, BLoC en structure et prévisibilité. Les deux solutions offrent une testabilité excellente et des performances optimales. ### Checklist de décision - ✅ Évaluer la taille et l'expérience de l'équipe - ✅ Considérer la complexité du flux de données - ✅ Analyser les besoins en traçabilité et debugging - ✅ Tester les deux solutions sur un prototype - ✅ Vérifier la cohérence avec l'architecture existante Le meilleur choix reste celui que l'équipe maîtrise et maintient efficacement. La consistance dans l'application prime sur le choix de la solution elle-même. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/flutter/flutter-state-management-riverpod-vs-bloc