Come scrivere Flutter code che resta “pulito” quando l’app cresce
Flutter rende facile creare app belle e performanti.
Il problema arriva dopo: quando l’app cresce e il codice inizia a degenerare.
Molti team cominciano con soluzioni semplici come Cubit o Provider, ma con il tempo i file diventano monolitici, pieni di logiche duplicate e accoppiamenti difficili da testare.
Nel nostro team, abbiamo affrontato questa sfida migrando da un approccio basato su Cubit a una struttura più modulare e scalabile, fondata su Riverpod, AsyncNotifier e ChangeNotifier.
Ecco come abbiamo ripulito tutto — e perché funziona meglio.
🚨 Il punto di partenza: logiche mischiate e Cubit “onniscienti”
All’inizio, tutto in un unico blocco sembrava comodo: caricamento dati, mutazioni di stato, side-effect e UI che osserva.
Ma appena le feature aumentano, il codice inizia a “marcire”.
Problemi tipici:
- Logiche di business mischiate nella UI
- Cubit con centinaia di righe
- Test difficili
- Rebuild inutili
- Accoppiamento tra layer
Ecco un Cubit “classico” che gestisce un elenco di elementi (Items):
class ItemsCubit extends Cubit<ItemsState> {
ItemsCubit(this.repo) : super(ItemsLoading());
final ItemsRepository repo;
List<Item> _cache = [];
Future<void> load() async {
emit(ItemsLoading());
final result = await repo.fetchAll();
_cache = result;
emit(ItemsLoaded(result));
}
Future<void> toggleFavorite(String id) async {
final item = _cache.firstWhere((e) => e.id == id);
final updated = item.copyWith(isFavorite: !item.isFavorite);
await repo.update(updated);
_cache[_cache.indexOf(item)] = updated;
emit(ItemsLoaded(List.of(_cache)));
}
}
Sembra innocuo, ma è il primo passo verso un codice difficile da mantenere e testare.
🧭 L’obiettivo: architettura pulita e reattiva
Abbiamo stabilito alcune regole semplici:
- Organizzazione per funzionalità, non per layer (feature-first)
- La UI non decide nulla: mostra solo lo stato
- Stato gestito con Riverpod e provider combinabili
- Side-effects isolati in use case o repository
- Test possibili grazie a provider overridabili
⚙️ 1. Dependency Injection chiara e componibile
Con Riverpod, ogni dipendenza diventa esplicita e facilmente sostituibile:
final itemsRepositoryProvider = Provider<ItemsRepository>((ref) {
final client = ref.watch(apiClientProvider);
return NetworkItemsRepository(client);
});
Niente singleton nascosti.
Ogni feature sa esattamente cosa usa.
⚡ 2. Stato asincrono con AsyncNotifier
Invece di un Cubit monolitico, separiamo la logica di business in un notifier dedicato:
class ItemsNotifier extends AsyncNotifier<List<Item>> {
@override
Future<List<Item>> build() async {
final repo = ref.watch(itemsRepositoryProvider);
return repo.fetchAll();
}
Future<void> toggleFavorite(String id) async {
final current = state.valueOrNull ?? const <Item>[];
final index = current.indexWhere((e) => e.id == id);
if (index == -1) return;
// Optimistic update
final updatedList = [...current];
updatedList[index] = updatedList[index].copyWith(
isFavorite: !updatedList[index].isFavorite,
);
state = AsyncValue.data(updatedList);
try {
await ref.read(itemsRepositoryProvider).update(updatedList[index]);
} catch (_) {
state = AsyncValue.data(current); // rollback
rethrow;
}
}
}
final itemsProvider =
AsyncNotifierProvider<ItemsNotifier, List<Item>>(() => ItemsNotifier());
✅ Caricamento, errori e aggiornamenti ottimistici gestiti nello stesso punto.
✅ Nessun bisogno di emit o copyWith infiniti.
🎛 3. Stato UI locale con ChangeNotifier
Per controlli di interfaccia (es. filtri, tab, preferenze), ChangeNotifier rimane la soluzione più leggera:
class ItemsUiController extends ChangeNotifier {
bool _showOnlyFavorites = false;
bool get showOnlyFavorites => _showOnlyFavorites;
void toggleFilter() {
_showOnlyFavorites = !_showOnlyFavorites;
notifyListeners();
}
}
final itemsUiControllerProvider =
ChangeNotifierProvider(() => ItemsUiController());
🧩 4. Provider derivati e selettivi
Riverpod permette di derivare nuovi stati in modo performante, usando select per ridurre i rebuild:
final visibleItemsProvider = Provider<List<Item>>((ref) {
final allItems = ref.watch(itemsProvider).value ?? const <Item>[];
final showFavorites =
ref.watch(itemsUiControllerProvider.select((c) => c.showOnlyFavorites));
return showFavorites
? allItems.where((i) => i.isFavorite).toList()
: allItems;
});
🖥 5. UI “stupida” ma reattiva
Il widget ora non ha logica — solo rendering e interazione:
class ItemsPage extends ConsumerWidget {
const ItemsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final itemsAsync = ref.watch(itemsProvider);
final visibleItems = ref.watch(visibleItemsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Items'),
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () =>
ref.read(itemsUiControllerProvider).toggleFilter(),
)
],
),
body: itemsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Errore: $e')),
data: (_) => ListView.builder(
itemCount: visibleItems.length,
itemBuilder: (_, i) {
final item = visibleItems[i];
return ListTile(
title: Text(item.name),
trailing: IconButton(
icon: Icon(
item.isFavorite
? Icons.favorite
: Icons.favorite_border,
),
onPressed: () =>
ref.read(itemsProvider.notifier).toggleFavorite(item.id),
),
);
},
),
),
);
}
}
🔍 I benefici reali
Il passaggio a Riverpod ci ha portato vantaggi concreti:
- Meno rebuild e performance più stabili
- Codice modulare, ogni feature vive isolata
- Test semplici grazie ai provider overridabili
- Side-effects chiari, rollback gestiti elegantemente
- UI snella, senza logiche di business
🧠 In sintesi
Passare da Cubit a Riverpod non è solo un cambio di state manager —
è un cambio di mentalità architetturale.
Riverpod ti costringe a pensare per responsabilità: chi fa cosa, dove e quando.
E quando l’app cresce, questa disciplina ripaga.
🚀 Vuoi imparare di più?
Leggi altri articoli nel nostro blog o cercami su Linkedin per essere sempre aggiornato sull’argomento e discuterne insieme!
Se invece vuoi imparare Flutter, iscriviti al nostro corso! Ti aspettiamo!
