Skip to content

Patterns avancés de gestion d'état

Pour une architecture robuste et maintenable, il est essentiel de séparer les responsabilités :

  1. Présentation : Les widgets d’interface utilisateur

    • Widgets, pages, composants UI
    • Gestion des interactions utilisateur
    • Affichage des données
  2. Logique métier : Les contrôleurs et services

    • Business logic, règles métier
    • Gestion d’état applicatif
    • Coordination des opérations
  3. Données : Les modèles et repositories

    • Accès aux données (API, base de données)
    • Modèles de données
    • Cache et persistance
// Couche Données
class UserRepository {
final ApiService _apiService;
final LocalStorage _localStorage;
UserRepository(this._apiService, this._localStorage);
Future<User> getUser(String id) async {
try {
return await _apiService.getUser(id);
} catch (e) {
return await _localStorage.getUser(id);
}
}
}
// Couche Logique métier
class UserController extends ChangeNotifier {
final UserRepository _repository;
UserController(this._repository);
User? _currentUser;
User? get currentUser => _currentUser;
Future<void> loadUser(String id) async {
_currentUser = await _repository.getUser(id);
notifyListeners();
}
}
// Couche Présentation
class UserProfileWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<UserController>(
builder: (context, controller, child) {
final user = controller.currentUser;
if (user == null) return CircularProgressIndicator();
return Column(
children: [
Text(user.name),
Text(user.email),
],
);
},
);
}
}

Utilisé par ValueNotifier et ChangeNotifier pour implémenter la réactivité :

abstract class Observable<T> {
void addListener(VoidCallback listener);
void removeListener(VoidCallback listener);
void notifyListeners();
}
class CustomNotifier<T> extends Observable<T> {
T _value;
T get value => _value;
final List<VoidCallback> _listeners = [];
CustomNotifier(this._value);
set value(T newValue) {
if (_value != newValue) {
_value = newValue;
notifyListeners();
}
}
@override
void addListener(VoidCallback listener) {
_listeners.add(listener);
}
@override
void removeListener(VoidCallback listener) {
_listeners.remove(listener);
}
@override
void notifyListeners() {
for (final listener in _listeners) {
listener();
}
}
}

Pour abstraire l’accès aux données et faciliter les tests :

abstract class TaskRepository {
Future<List<Task>> getTasks();
Future<Task> createTask(CreateTaskRequest request);
Future<Task> updateTask(String id, UpdateTaskRequest request);
Future<void> deleteTask(String id);
}
class ApiTaskRepository implements TaskRepository {
final ApiService _apiService;
ApiTaskRepository(this._apiService);
@override
Future<List<Task>> getTasks() async {
final response = await _apiService.get('/tasks');
return response.data.map((json) => Task.fromJson(json)).toList();
}
@override
Future<Task> createTask(CreateTaskRequest request) async {
final response = await _apiService.post('/tasks', request.toJson());
return Task.fromJson(response.data);
}
// ... autres méthodes
}
class MockTaskRepository implements TaskRepository {
final List<Task> _tasks = [];
@override
Future<List<Task>> getTasks() async {
return List.from(_tasks);
}
@override
Future<Task> createTask(CreateTaskRequest request) async {
final task = Task(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: request.title,
description: request.description,
isCompleted: false,
);
_tasks.add(task);
return task;
}
// ... autres méthodes
}

Via les InheritedWidget et Provider pour l’injection de dépendances :

class ServiceLocator extends InheritedWidget {
final Map<Type, dynamic> _services = {};
ServiceLocator({required Widget child}) : super(child: child);
void register<T>(T service) {
_services[T] = service;
}
T get<T>() {
final service = _services[T];
if (service == null) {
throw Exception('Service ${T.toString()} not registered');
}
return service as T;
}
static ServiceLocator of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ServiceLocator>()!;
}
@override
bool updateShouldNotify(ServiceLocator oldWidget) => false;
}

Pattern avec ChangeNotifier pour séparer la logique de présentation :

// Model
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// ViewModel
class UserProfileViewModel extends ChangeNotifier {
final UserRepository _repository;
UserProfileViewModel(this._repository);
User? _user;
User? get user => _user;
bool _isLoading = false;
bool get isLoading => _isLoading;
String? _errorMessage;
String? get errorMessage => _errorMessage;
bool get hasError => _errorMessage != null;
Future<void> loadUser(String userId) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
_user = await _repository.getUser(userId);
} catch (e) {
_errorMessage = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> updateUserName(String newName) async {
if (_user == null) return;
_isLoading = true;
notifyListeners();
try {
_user = await _repository.updateUser(_user!.id, name: newName);
} catch (e) {
_errorMessage = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}
// View
class UserProfileView extends StatelessWidget {
final String userId;
UserProfileView({required this.userId});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => UserProfileViewModel(context.read<UserRepository>()),
child: _UserProfileContent(userId: userId),
);
}
}
class _UserProfileContent extends StatefulWidget {
final String userId;
_UserProfileContent({required this.userId});
@override
_UserProfileContentState createState() => _UserProfileContentState();
}
class _UserProfileContentState extends State<_UserProfileContent> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<UserProfileViewModel>().loadUser(widget.userId);
});
}
@override
Widget build(BuildContext context) {
return Consumer<UserProfileViewModel>(
builder: (context, viewModel, child) {
if (viewModel.isLoading) {
return Center(child: CircularProgressIndicator());
}
if (viewModel.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Erreur: ${viewModel.errorMessage}'),
ElevatedButton(
onPressed: () => viewModel.loadUser(widget.userId),
child: Text('Réessayer'),
),
],
),
);
}
final user = viewModel.user;
if (user == null) {
return Center(child: Text('Aucun utilisateur trouvé'));
}
return Column(
children: [
Text('Nom: ${user.name}'),
Text('Email: ${user.email}'),
ElevatedButton(
onPressed: () => _showEditDialog(context, viewModel),
child: Text('Modifier'),
),
],
);
},
);
}
void _showEditDialog(BuildContext context, UserProfileViewModel viewModel) {
// Dialog pour modifier le nom
}
}

Sealed Classes pour la gestion d’état asynchrone

Section titled “Sealed Classes pour la gestion d’état asynchrone”

Les sealed classes permettent de modéliser différents états de manière type-safe :

sealed class AsyncTask<T> {}
class PendingTask<T> extends AsyncTask<T> {}
class TaskError<T> extends AsyncTask<T> {
final String error;
TaskError([this.error = 'Error !']);
}
class AsyncResult<T> extends AsyncTask<T> {
final T data;
AsyncResult(this.data);
}
class AsyncDataController<T> extends ChangeNotifier {
AsyncTask<T> _state = PendingTask<T>();
AsyncTask<T> get state => _state;
Future<void> execute(Future<T> Function() operation) async {
_state = PendingTask<T>();
notifyListeners();
try {
final result = await operation();
_state = AsyncResult<T>(result);
} catch (e) {
_state = TaskError<T>(e.toString());
}
notifyListeners();
}
}
class AsyncDataWidget<T> extends StatelessWidget {
final AsyncDataController<T> controller;
final Widget Function(T data) dataBuilder;
final Widget Function(String error)? errorBuilder;
final Widget? loadingWidget;
AsyncDataWidget({
required this.controller,
required this.dataBuilder,
this.errorBuilder,
this.loadingWidget,
});
@override
Widget build(BuildContext context) {
return Consumer<AsyncDataController<T>>(
builder: (context, controller, child) {
return switch (controller.state) {
PendingTask<T>() => loadingWidget ?? CircularProgressIndicator(),
TaskError<T>(error: final error) => errorBuilder?.call(error) ??
Text('Erreur: $error'),
AsyncResult<T>(data: final data) => dataBuilder(data),
};
},
);
}
}

La solution native Flutter pour partager des données dans l’arbre de widgets :

class UserInheritedWidget extends InheritedWidget {
final User user;
UserInheritedWidget({
required this.user,
required Widget child,
}) : super(child: child);
@override
bool updateShouldNotify(UserInheritedWidget oldWidget) {
return user != oldWidget.user;
}
static UserInheritedWidget? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<UserInheritedWidget>();
}
}

Solution basée sur les streams pour une gestion d’état réactive :

// Avec le package flutter_bloc
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}
// Utilisation
BlocProvider(
create: (context) => CounterCubit(),
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) {
return Text('$count');
},
),
)

Version améliorée de Provider avec meilleure sécurité de type :

final counterProvider = StateProvider<int>((ref) => 0);
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('$count'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Text('Increment'),
),
],
);
}
}

Gestion d’état réactive avec génération de code :

import 'package:mobx/mobx.dart';
part 'counter_store.g.dart';
class CounterStore = _CounterStore with _$CounterStore;
abstract class _CounterStore with Store {
@observable
int count = 0;
@action
void increment() {
count++;
}
}
// ✅ Bon : logique séparée
class TaskListController extends ChangeNotifier {
final TaskRepository _repository;
TaskListController(this._repository);
List<Task> _tasks = [];
List<Task> get tasks => List.unmodifiable(_tasks);
Future<void> loadTasks() async {
_tasks = await _repository.getTasks();
notifyListeners();
}
}
// ❌ Mauvais : logique dans le widget
class TaskListWidget extends StatefulWidget {
@override
_TaskListWidgetState createState() => _TaskListWidgetState();
}
class _TaskListWidgetState extends State<TaskListWidget> {
List<Task> tasks = [];
@override
void initState() {
super.initState();
// ❌ Logique métier dans le widget
loadTasks();
}
Future<void> loadTasks() async {
final response = await http.get('/tasks');
setState(() {
tasks = parseTasksFromJson(response.body);
});
}
}
void main() {
group('TaskListController', () {
late TaskListController controller;
late MockTaskRepository mockRepository;
setUp(() {
mockRepository = MockTaskRepository();
controller = TaskListController(mockRepository);
});
test('should load tasks from repository', () async {
final expectedTasks = [Task(id: '1', title: 'Test Task')];
mockRepository.setTasks(expectedTasks);
await controller.loadTasks();
expect(controller.tasks, equals(expectedTasks));
});
});
}
// ✅ Bon : utilise Selector pour optimiser
Selector<CartController, int>(
selector: (context, cart) => cart.itemCount,
builder: (context, itemCount, child) {
return Text('$itemCount items');
},
)
// ❌ Mauvais : rebuild inutile
Consumer<CartController>(
builder: (context, cart, child) {
return Text('${cart.itemCount} items'); // Rebuild même si seul itemCount change
},
)
class MyController extends ChangeNotifier {
final StreamSubscription _subscription;
MyController() {
_subscription = someStream.listen(_handleData);
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
void _handleData(dynamic data) {
// Traiter les données
notifyListeners();
}
}
// ✅ Bon : hiérarchie logique
MultiProvider(
providers: [
// Services de base (sans dépendances)
Provider<ApiService>(create: (context) => ApiService()),
Provider<LocalStorage>(create: (context) => LocalStorage()),
// Services avec dépendances
ProxyProvider2<ApiService, LocalStorage, UserRepository>(
update: (context, api, storage, previous) =>
UserRepository(api, storage),
),
// Controllers
ChangeNotifierProxyProvider<UserRepository, UserController>(
create: (context) => UserController(),
update: (context, repository, controller) =>
controller..updateRepository(repository),
),
],
child: MyApp(),
)

Le choix de la solution de gestion d’état dépend de la complexité de votre application :

  • Applications simples : setState et StatefulWidget
  • Applications moyennes : ValueNotifier, ChangeNotifier et Provider
  • Applications complexes : Provider avec patterns avancés, Bloc, ou Riverpod
  1. Séparation des responsabilités : Vue, Logique, Données
  2. Testabilité : Logique métier indépendante des widgets
  3. Performance : Minimiser les rebuilds inutiles
  4. Maintenabilité : Architecture claire et patterns cohérents

L’important est de maintenir une architecture claire qui facilite la maintenance, les tests et l’évolution de votre application Flutter.