Perché studiare gli InheritedWidget nel 2024

Ogni volta che scrivi Theme.of(context), MediaQuery.sizeOf(context) o Navigator.of(context) stai usando un InheritedWidget. Lo stesso vale per Provider, per lo ProviderScope di Riverpod e per il BlocProvider di flutter_bloc: sotto il cofano c'è sempre quel meccanismo.

Conoscerlo non è archeologia del framework: serve a capire perché un widget si ricostruisce, perché context a volte "non trova" quello che cerca, e come propagare dati senza aggiungere dipendenze esterne. In molte app un piccolo InheritedWidget scritto a mano è la soluzione più leggera e leggibile.

Il modello mentale: tre alberi, un solo lookup

Flutter mantiene tre alberi paralleli:

  • Widget tree: la configurazione immutabile che descrivi nel build.
  • Element tree: le istanze persistenti che collegano widget e render object. Il BuildContext è un Element.
  • Render tree: ciò che viene effettivamente misurato e disegnato.

Quando chiami context.dependOnInheritedWidgetOfExactType<T>(), Flutter non scorre l'albero ogni volta: ogni Element mantiene una mappa (_inheritedElements) degli InheritedWidget disponibili sopra di lui, ereditata dal genitore. Il lookup è quindi un accesso a una hash map, O(1), non una risalita lineare. Per questo Theme.of(context) è economico anche in alberi profondi.

La parte costosa non è la lettura: è la dipendenza che viene registrata. Chi legge con dependOn... viene aggiunto alla lista dei dipendenti e sarà ricostruito ogni volta che l'InheritedWidget cambia (se updateShouldNotify restituisce true).

Un InheritedWidget scritto a mano

Ecco il pattern canonico, con la coppia of / maybeOf che ormai è convenzione nel framework:

class AppConfig extends InheritedWidget {
  const AppConfig({
    super.key,
    required this.apiBaseUrl,
    required this.isBetaUser,
    required super.child,
  });

  final String apiBaseUrl;
  final bool isBetaUser;

  static AppConfig? maybeOf(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<AppConfig>();

  static AppConfig of(BuildContext context) {
    final config = maybeOf(context);
    assert(config != null, 'Nessun AppConfig trovato sopra questo context');
    return config!;
  }

  @override
  bool updateShouldNotify(AppConfig oldWidget) =>
      apiBaseUrl != oldWidget.apiBaseUrl || isBetaUser != oldWidget.isBetaUser;
}

Tre punti da non sottovalutare:

  1. updateShouldNotify è il tuo filtro anti-rebuild. Se restituisci sempre true, ogni ricostruzione dell'antenato ricostruisce tutti i dipendenti. Confronta i campi che contano davvero.
  2. I campi devono essere immutabili (final). Se muti un oggetto interno senza sostituire il widget, il framework non ha modo di accorgersene.
  3. of lancia in assert, maybeOf restituisce null: dai al chiamante la possibilità di scegliere.

Stato mutabile: InheritedWidget + StatefulWidget

Un InheritedWidget è immutabile, quindi per gestire stato lo si accoppia a uno StatefulWidget che lo ricrea:

class CartScope extends StatefulWidget {
  const CartScope({super.key, required this.child});

  final Widget child;

  static CartController of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<_CartInherited>()!.controller;

  @override
  State<CartScope> createState() => _CartScopeState();
}

class _CartScopeState extends State<CartScope> {
  final CartController controller = CartController();

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>
      _CartInherited(controller: controller, child: widget.child);
}

class _CartInherited extends InheritedWidget {
  const _CartInherited({required this.controller, required super.child});

  final CartController controller;

  @override
  bool updateShouldNotify(_CartInherited oldWidget) =>
      controller != oldWidget.controller;
}

Qui updateShouldNotify confronta l'identità del controller: il controller in sé non cambia mai, quindi nessuno viene ricostruito inutilmente. La notifica dei cambiamenti la deleghi al controller stesso, che tipicamente è un ChangeNotifier o espone uno Stream.

InheritedNotifier: la scorciatoia per i ChangeNotifier

Se il tuo stato è un Listenable (ChangeNotifier, ValueNotifier, AnimationController), InheritedNotifier fa il lavoro sporco: si iscrive al notifier e ricostruisce i dipendenti a ogni notifyListeners().

class ThemeModeScope extends InheritedNotifier<ValueNotifier<ThemeMode>> {
  const ThemeModeScope({
    super.key,
    required ValueNotifier<ThemeMode> super.notifier,
    required super.child,
  });

  static ValueNotifier<ThemeMode> of(BuildContext context) => context
      .dependOnInheritedWidgetOfExactType<ThemeModeScope>()!
      .notifier!;
}

// Uso
final mode = ThemeModeScope.of(context).value;
// ...
ThemeModeScope.of(context).value = ThemeMode.dark;

Attenzione: la granularità è quella del notifier intero. Chi legge viene ricostruito anche se il campo che gli interessa non è cambiato. Per stati grandi, spezza in più scope o usa InheritedModel.

InheritedModel: dipendenze per "aspetto"

InheritedModel permette a un widget di dipendere solo da una fetta dei dati. Ogni dipendente dichiara l'aspetto che gli interessa e viene ricostruito solo se quell'aspetto cambia.

enum UserAspect { name, avatar }

class UserScope extends InheritedModel<UserAspect> {
  const UserScope({
    super.key,
    required this.name,
    required this.avatarUrl,
    required super.child,
  });

  final String name;
  final String avatarUrl;

  static UserScope of(BuildContext context, UserAspect aspect) =>
      InheritedModel.inheritFrom<UserScope>(context, aspect: aspect)!;

  @override
  bool updateShouldNotify(UserScope old) =>
      name != old.name || avatarUrl != old.avatarUrl;

  @override
  bool updateShouldNotifyDependent(UserScope old, Set<UserAspect> aspects) {
    if (aspects.contains(UserAspect.name) && name != old.name) return true;
    if (aspects.contains(UserAspect.avatar) && avatarUrl != old.avatarUrl) {
      return true;
    }
    return false;
  }
}

Un widget che mostra solo l'avatar userà UserScope.of(context, UserAspect.avatar) e ignorerà i cambi di nome. È esattamente la tecnica adottata dal framework per MediaQuery: da Flutter 3.10 esistono MediaQuery.sizeOf(context), paddingOf, viewInsetsOf… proprio per evitare che un cambio di padding ricostruisca chi legge solo la dimensione.

Best practice immediata: nel codice nuovo preferisci MediaQuery.sizeOf(context) a MediaQuery.of(context).size. È una riga che elimina rebuild inutili, per esempio all'apertura della tastiera.

dependOn vs getElementForInheritedWidgetOfExactType

Esistono due modi di leggere:

  • dependOnInheritedWidgetOfExactType<T>() — legge e registra la dipendenza. Da usare nel build o in didChangeDependencies.
  • getInheritedWidgetOfExactType<T>() (o getElementForInheritedWidgetOfExactType<T>()?.widget as T) — legge senza dipendere. Utile in initState, in dispose o nei callback, quando ti serve solo un riferimento stabile (es. un controller) e non vuoi essere ricostruito.
@override
void initState() {
  super.initState();
  // OK: nessuna dipendenza registrata
  final controller = context
      .getInheritedWidgetOfExactType<_CartInherited>()!
      .controller;
  controller.load();
}

Chiamare dependOn... in initState genera un'eccezione, perché in quella fase l'element non può ancora registrare dipendenze in modo sicuro. Se ti serve reagire ai cambiamenti, il posto giusto è didChangeDependencies.

I tre errori più comuni con il BuildContext

1. Il context sbagliato

// SBAGLIATO: il context di build() è sopra lo Scaffold
Scaffold(
  body: Builder(
    builder: (innerContext) => ElevatedButton(
      onPressed: () => Scaffold.of(innerContext).openDrawer(),
      child: const Text('Apri'),
    ),
  ),
)

Un lookup vede solo gli antenati del proprio element. Se l'InheritedWidget è creato nello stesso build, serve un Builder (o un widget separato) per ottenere un context più in basso.

2. Leggere dopo un await

Future<void> _save(BuildContext context) async {
  await repository.save();
  if (!context.mounted) return; // indispensabile
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('Salvato')),
  );
}

Dal 2023 BuildContext.mounted è disponibile direttamente e il lint use_build_context_synchronously lo segnala: attivalo in analysis_options.yaml.

3. Dipendere da tutto per usare poco

Theme.of(context) in un widget che usa solo un colore va benissimo, ma MediaQuery.of(context) per la sola larghezza è uno spreco. Regola pratica: dipendi dal minimo indispensabile e il più in basso possibile nell'albero.

Quando usare un InheritedWidget e quando no

Usalo quando:

  • devi passare una dipendenza a un sottoalbero senza attraversare dieci costruttori (prop drilling);
  • stai scrivendo un package e non vuoi imporre una libreria di state management;
  • il dato è di scope (tema, configurazione, sessione, controller di una schermata complessa).

Preferisci una soluzione dedicata (Riverpod, BLoC, Provider) quando ti servono: cache e disposal automatici, dipendenze fra provider, gestione di stati asincroni, testabilità con override. Quelle librerie non sostituiscono l'InheritedWidget: lo incapsulano aggiungendo ergonomia.

Verificare i rebuild

Per capire se le tue dipendenze sono troppo larghe, in DevTools attiva Track Widget Rebuilds (Flutter Inspector) oppure abilita temporaneamente:

import 'package:flutter/rendering.dart';

void main() {
  debugPrintRebuildDirtyWidgets = true; // solo in debug
  runApp(const MyApp());
}

Se vedi ricostruzioni a raffica all'apertura della tastiera o alla rotazione, quasi sempre la causa è un MediaQuery.of(context) posizionato troppo in alto nell'albero.

In sintesi

  • Il BuildContext è un Element: il lookup degli InheritedWidget è O(1) grazie a una mappa mantenuta da ogni element.
  • updateShouldNotify decide chi si ricostruisce: scrivilo con cura.
  • InheritedNotifier semplifica l'integrazione con i Listenable; InheritedModel offre dipendenze granulari per aspetto.
  • Usa dependOn... nel build, get... in initState/dispose.
  • Preferisci le API "of" granulari (MediaQuery.sizeOf) e proteggi sempre il context dopo un await con context.mounted.

Padroneggiare questo livello del framework ti permette di scegliere consapevolmente lo strumento giusto — e di debuggare in cinque minuti problemi che altrimenti sembrano magia nera.