Perché GraphQL in un'app Flutter

Sempre più backend espongono un endpoint GraphQL accanto (o al posto) delle classiche API REST: Hasura, Shopify, GitHub, Strapi, WordPress con WPGraphQL, Apollo Server. Il vantaggio per un client mobile è concreto: si chiede esattamente ciò che serve, in una sola richiesta, evitando l'over-fetching tipico del REST e le decine di endpoint "su misura" da mantenere.

In Flutter il pacchetto di riferimento è graphql_flutter, costruito sopra il core graphql. Offre client HTTP, cache normalizzata persistente, widget dichiarativi, optimistic UI e subscription via WebSocket. In questa guida vediamo come integrarlo in modo pulito, senza trasformare i widget in un groviglio di stringhe GraphQL.

Setup del progetto

dependencies:
  graphql_flutter: ^5.2.0

dev_dependencies:
  build_runner: ^2.4.13
  graphql_codegen: ^0.14.0

La cache può essere persistita su disco tramite Hive, che va inizializzato prima di runApp:

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initHiveForFlutter(); // crea il box per HiveStore
  runApp(const MyApp());
}

Costruire il client

Un GraphQLClient è composto da una catena di Link (middleware di trasporto) e da una cache. Un setup tipico prevede un HttpLink, un AuthLink per il token e — se servono le subscription — uno split verso il WebSocketLink.

class GraphQLClientFactory {
  static ValueNotifier<GraphQLClient> create(TokenStorage tokens) {
    final httpLink = HttpLink('https://api.example.com/graphql');

    final authLink = AuthLink(
      getToken: () async {
        final token = await tokens.read();
        return token == null ? null : 'Bearer $token';
      },
    );

    final wsLink = WebSocketLink(
      'wss://api.example.com/graphql',
      config: const SocketClientConfig(
        autoReconnect: true,
        inactivityTimeout: Duration(seconds: 30),
      ),
    );

    final link = Link.split(
      (request) => request.isSubscription,
      wsLink,
      authLink.concat(httpLink),
    );

    return ValueNotifier(
      GraphQLClient(
        link: link,
        cache: GraphQLCache(store: HiveStore()),
        defaultPolicies: DefaultPolicies(
          query: Policies(fetch: FetchPolicy.cacheAndNetwork),
        ),
      ),
    );
  }
}

Il client va reso disponibile all'albero dei widget con GraphQLProvider:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return GraphQLProvider(
      client: GraphQLClientFactory.create(TokenStorage()),
      child: const CacheProvider(child: MaterialApp(home: ProductsPage())),
    );
  }
}

Le fetch policy: il cuore del comportamento offline

Capire le policy evita il 90% dei problemi di "dati vecchi" o di spinner infiniti:

  • cacheFirst: se il dato è in cache lo restituisce senza toccare la rete. Ottimo per contenuti statici.
  • cacheAndNetwork: emette subito il dato in cache e poi aggiorna con la risposta di rete. È la scelta migliore per le liste, perché elimina il flash di loading.
  • networkOnly: ignora la cache in lettura ma la aggiorna. Utile per dati sensibili (saldo, ordini).
  • cacheOnly: solo cache, ideale per schermate offline.
  • noCache: né legge né scrive. Da usare per dati one-shot o molto grandi.

Query dichiarative con il widget Query

const productsQuery = r'''
  query Products($first: Int!, $after: String) {
    products(first: $first, after: $after) {
      pageInfo { hasNextPage endCursor }
      nodes { id __typename name price }
    }
  }
''';

Query(
  options: QueryOptions(
    document: gql(productsQuery),
    variables: const {'first': 20},
  ),
  builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {
    if (result.hasException) {
      return ErrorView(message: result.exception.toString(), onRetry: refetch);
    }
    if (result.isLoading && result.data == null) {
      return const Center(child: CircularProgressIndicator());
    }

    final nodes = (result.data!['products']['nodes'] as List).cast<Map<String, dynamic>>();
    return ListView.builder(
      itemCount: nodes.length,
      itemBuilder: (_, i) => ListTile(
        title: Text(nodes[i]['name'] as String),
        subtitle: Text('${nodes[i]['price']} €'),
      ),
    );
  },
)

Nota il result.isLoading && result.data == null: con cacheAndNetwork il risultato può essere contemporaneamente loading e già popolato dalla cache. Distinguere i due casi è ciò che rende l'app fluida.

Paginazione con fetchMore

fetchMore!(
  FetchMoreOptions(
    variables: {'after': pageInfo['endCursor']},
    updateQuery: (previous, fetchMoreResult) {
      final oldNodes = previous!['products']['nodes'] as List;
      final newNodes = fetchMoreResult!['products']['nodes'] as List;
      return {
        'products': {
          ...fetchMoreResult['products'],
          'nodes': [...oldNodes, ...newNodes],
        },
      };
    },
  ),
);

Mutation e optimistic UI

Le mutation possono aggiornare l'interfaccia prima della risposta del server grazie all'optimisticResult, che viene scritto temporaneamente in cache e sostituito (o rimosso in caso di errore) quando arriva la risposta reale.

const toggleFavorite = r'''
  mutation ToggleFavorite($id: ID!, $value: Boolean!) {
    setFavorite(productId: $id, value: $value) {
      id __typename isFavorite
    }
  }
''';

Mutation(
  options: MutationOptions(
    document: gql(toggleFavorite),
    onError: (error) => showSnackBar('Operazione non riuscita'),
    update: (cache, result) {
      if (result?.data == null) return;
      cache.writeFragment(
        Fragment(document: gql(r'''
          fragment Fav on Product { id __typename isFavorite }
        ''')).asRequest(idFields: {
          '__typename': 'Product',
          'id': result!.data!['setFavorite']['id'],
        }),
        data: result.data!['setFavorite'],
      );
    },
  ),
  builder: (RunMutation runMutation, QueryResult? result) {
    return IconButton(
      icon: Icon(product.isFavorite ? Icons.favorite : Icons.favorite_border),
      onPressed: () => runMutation(
        {'id': product.id, 'value': !product.isFavorite},
        optimisticResult: {
          'setFavorite': {
            '__typename': 'Product',
            'id': product.id,
            'isFavorite': !product.isFavorite,
          },
        },
      ),
    );
  },
)

Come funziona la cache normalizzata

GraphQLCache non memorizza le risposte "per query": normalizza gli oggetti in una mappa piatta usando come chiave la combinazione __typename + id (o _id). Il risultato è che, se aggiorni un Product da una mutation o da un dettaglio, tutte le liste che lo contengono si aggiornano automaticamente.

Due conseguenze pratiche da ricordare:

  1. Includi sempre id e __typename nelle query, altrimenti l'oggetto viene memorizzato "inline" nella query padre e perdi la propagazione automatica.
  2. Se il tuo schema usa una chiave diversa, configurala con le typePolicies:
GraphQLCache(
  store: HiveStore(),
  typePolicies: {
    'Product': TypePolicy(keyFields: {'sku': true}),
  },
)

Per i dati parziali (utile con cacheAndNetwork su schermate di dettaglio) puoi abilitare partialDataPolicy: PartialDataCachePolicy.acceptForOptimisticData.

Subscription realtime

Con il WebSocketLink già configurato, una subscription è un widget:

Subscription(
  options: SubscriptionOptions(
    document: gql(r'''
      subscription OnOrderUpdated($id: ID!) {
        orderUpdated(orderId: $id) { id __typename status updatedAt }
      }
    '''),
    variables: {'id': orderId},
  ),
  builder: (result) {
    if (result.isLoading) return const LinearProgressIndicator();
    final status = result.data?['orderUpdated']['status'] as String?;
    return Text('Stato ordine: ${status ?? '—'}');
  },
)

Attenzione al protocollo: molti server moderni usano graphql-transport-ws, mentre altri (Hasura in configurazioni legacy, Apollo Server 2) usano subscriptions-transport-ws. Il parametro protocols di SocketClientConfig permette di scegliere quello corretto.

Type-safety con graphql_codegen

Scrivere query come stringhe e leggere i risultati con result.data!['products']['nodes'] è fragile. graphql_codegen genera classi Dart a partire dallo schema e dai file .graphql.

# build.yaml
targets:
  $default:
    builders:
      graphql_codegen:
        options:
          scalars:
            DateTime:
              type: DateTime
              fromJsonFunctionName: dateTimeFromJson
          clients:
            - graphql_flutter

Metti le operazioni in lib/graphql/products.graphql e lo schema in lib/graphql/schema.graphql, poi esegui:

dart run build_runner build --delete-conflicting-outputs

Ottieni widget e helper tipizzati, ad esempio:

Query$Products$Widget(
  options: Options$Query$Products(
    variables: Variables$Query$Products(first: 20),
  ),
  builder: (result, {refetch, fetchMore}) {
    final products = result.parsedData?.products.nodes ?? [];
    return ProductList(products: products); // oggetti Dart, non Map
  },
)

Usare GraphQL fuori dai widget

Se segui un'architettura a livelli, i widget non dovrebbero conoscere GraphQL. Incapsula il client in un repository e usa le API imperative:

class ProductRepository {
  ProductRepository(this._client);
  final GraphQLClient _client;

  Future<List<Product>> fetchProducts({int first = 20}) async {
    final result = await _client.query(
      QueryOptions(
        document: gql(productsQuery),
        variables: {'first': first},
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
    );

    if (result.hasException) {
      throw ProductException.from(result.exception!);
    }
    final nodes = result.data!['products']['nodes'] as List;
    return nodes.map((e) => Product.fromJson(e as Map<String, dynamic>)).toList();
  }
}

In questo modo puoi combinare GraphQL con Riverpod, BLoC o qualunque altra soluzione di gestione dello stato, e sostituire il trasporto senza toccare la UI.

Gestione degli errori: due famiglie distinte

OperationException separa nettamente:

  • linkException: problemi di trasporto (nessuna rete, timeout, risposta non JSON, 401 a livello HTTP).
  • graphqlErrors: errori applicativi restituiti dal server con status 200, ciascuno con message, path e extensions (spesso contengono un code come UNAUTHENTICATED o FORBIDDEN).
String mapError(OperationException e) {
  if (e.linkException != null) return 'Connessione non disponibile';
  final code = e.graphqlErrors.firstOrNull?.extensions?['code'];
  return switch (code) {
    'UNAUTHENTICATED' => 'Sessione scaduta, effettua di nuovo il login',
    'FORBIDDEN' => 'Permessi insufficienti',
    _ => e.graphqlErrors.firstOrNull?.message ?? 'Errore imprevisto',
  };
}

Un risultato può contenere sia dati sia errori (risposta parziale): controlla sempre result.data prima di mostrare una schermata di errore totale.

Best practice finali

  • Un file .graphql per feature, mai query inline sparse nei widget.
  • Usa i fragment per condividere la selezione di campi tra lista e dettaglio: garantiscono che la cache abbia sempre gli stessi campi.
  • Chiama GraphQLClient.resetStore() (o cache.store.reset()) al logout: la cache persistita contiene dati dell'utente precedente.
  • Evita pollInterval aggressivi su mobile: consumano batteria. Preferisci le subscription o un refresh legato al lifecycle dell'app.
  • Attiva la persisted query (APQ) se il server la supporta: riduce il payload inviando l'hash della query invece del testo.
  • Non mettere il token direttamente nell'HttpLink: l'AuthLink con getToken asincrono permette di leggere il token aggiornato a ogni richiesta.

GraphQL non è sempre la scelta giusta — per API semplici REST resta più immediato — ma quando lo schema è ricco e le schermate aggregano molte entità, la combinazione di query mirate e cache normalizzata riduce drasticamente sia il traffico sia il codice di sincronizzazione che saresti costretto a scrivere a mano.