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:
- Includi sempre
ide__typenamenelle query, altrimenti l'oggetto viene memorizzato "inline" nella query padre e perdi la propagazione automatica. - 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 conmessage,patheextensions(spesso contengono uncodecomeUNAUTHENTICATEDoFORBIDDEN).
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
.graphqlper 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()(ocache.store.reset()) al logout: la cache persistita contiene dati dell'utente precedente. - Evita
pollIntervalaggressivi 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'AuthLinkcongetTokenasincrono 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.