Perché passare da Cubit a Riverpod

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!

Condividi il post:

Leggi anche