REST gets you moving fast, but the moment your app needs exactly the right fields and nothing more, flutter graphql starts to pay for itself. Instead of juggling three endpoints and trimming payloads on the client, you describe the data you want in one query and the server hands it back in exactly that shape. This practical introduction to Flutter and GraphQL walks you through setting up a real client, running queries and mutations, and keeping your cache sane — with code you can drop into a production app today.
Why GraphQL Fits Flutter Apps
A typical REST screen pulls /user, /user/orders, and /user/notifications — three round trips, three models, and over-fetching on every one. With flutter graphql, you ask for user { name orders { id total } } once and get back exactly that. Fewer requests, smaller payloads, and no more DTOs full of fields you never render.
GraphQL is also strongly typed. The schema is a contract you can introspect, which means the client knows the shape of the data before you write a single line of UI code. In Dart terms, that type safety feels familiar — it's why the two fit together so well.
The main client options:
| Package | Style | Best for |
|---|---|---|
graphql_flutter |
Widgets + hooks | Most apps, batteries included |
ferry |
Code-generated | Large schemas, strict typing |
artemis |
Codegen only | Small teams that want minimal runtime |
For this guide we'll use graphql_flutter — it's the pragmatic default and maps cleanly onto Flutter's widget model.
Setting Up graphql_flutter
Add the dependencies:
dependencies:
graphql_flutter: ^5.1.0
Create a single client that your whole app shares. Use ValueNotifier so GraphQLProvider can react to auth header changes later:
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
final HttpLink httpLink = HttpLink(
'https://api.example.com/graphql',
defaultHeaders: {
'Authorization': 'Bearer <token>',
},
);
final ValueNotifier<GraphQLClient> client = ValueNotifier(
GraphQLClient(
link: httpLink,
cache: GraphQLCache(store: HiveStore()),
),
);
Note HiveStore() — it gives you a normalized, persistent cache out of the box, so queries survive app restarts. Call await initHiveForFlutter(); in main() before runApp, then wrap your MaterialApp with GraphQLProvider:
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await initHiveForFlutter();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return GraphQLProvider(
client: client,
child: const MaterialApp(home: UsersScreen()),
);
}
}
Your First Query
Write the query as a plain string and let the Query widget handle loading, error, and data states:
const String usersQuery = r'''
query GetUsers {
users {
id
name
avatarUrl
}
}
''';
class UsersScreen extends StatelessWidget {
const UsersScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Team')),
body: Query(
options: QueryOptions(
document: gql(usersQuery),
fetchPolicy: FetchPolicy.cacheAndNetwork,
),
builder: (QueryResult result, {fetchMore, refetch}) {
if (result.isLoading && result.data == null) {
return const Center(child: CircularProgressIndicator());
}
if (result.hasException) {
return Center(child: Text('Error: ${result.exception}'));
}
final users = result.data!['users'] as List;
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, i) => ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(users[i]['avatarUrl']),
),
title: Text(users[i]['name']),
),
);
},
),
);
}
}
Two things worth noting. First, r''' raw strings keep ${...} and \\ in your GraphQL untouched. Second, cacheAndNetwork shows cached data instantly and refreshes in the background — the sweet spot for most list screens.
Mutations: Writing Data Back
Queries read; mutations write. Use the Mutation widget, which hands you a runMutation callback:
const String createUserMutation = r'''
mutation CreateUser($name: String!, $email: String!) {
createUser(name: $name, email: $email) {
id
name
}
}
''';
Mutation(
options: MutationOptions(document: gql(createUserMutation)),
builder: (runMutation, result) {
return ElevatedButton(
onPressed: () {
runMutation({
'name': nameController.text,
'email': emailController.text,
});
},
child: const Text('Create user'),
);
},
onCompleted: (data) {
// The cache updates automatically thanks to the normalized store.
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User created')),
);
},
);
Because the cache is normalized by object id, a mutation that returns the new object with its fields usually updates every screen showing it — no manual refetch() needed in the common case.
Error Handling That Scales
result.exception gives you two error buckets: linkException (network, auth, transport) and graphqlErrors (server-side validation or business logic). Handle them separately:
String userMessage(QueryResult result) {
final ex = result.exception;
if (ex == null) return '';
if (ex.linkException != null) {
return 'Network error — check your connection and try again.';
}
final errors = ex.graphqlErrors;
if (errors.isNotEmpty) return errors.first.message;
return 'Something went wrong.';
}
For auth specifically, wire an AuthLink that refreshes tokens and retries — don't hardcode a bearer string forever:
final AuthLink authLink = AuthLink(
getToken: () async => 'Bearer ${await tokenStorage.read()}',
);
final Link link = authLink.concat(httpLink);
Fetch Policies and Pagination
The default fetch policy is cacheFirst, which is fine for static data. For screens that must be fresh, reach for networkOnly or cacheAndNetwork. For infinite lists, use cursor-based pagination with fetchMore:
builder: (result, {fetchMore, refetch}) {
final posts = result.data!['posts'] as List;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, i) {
if (i == posts.length - 1) {
fetchMore?.call(FetchMoreOptions(
variables: {'after': result.data!['pageInfo']['endCursor']},
updateQuery: (prev, next) => {
...prev!,
'posts': [...prev['posts'], ...next!['posts']],
},
));
}
return PostTile(post: posts[i]);
},
);
},
This is where flutter graphql quietly beats most REST helpers: pagination logic lives in one updateQuery closure instead of a pile of manual list-merging code.
Subscriptions: Real-Time Updates
Queries and mutations cover most apps, but feeds, chats, and live dashboards need push. GraphQL subscriptions run over a websocket, and graphql_flutter handles the transport — you just add a WebSocketLink:
final wsLink = WebSocketLink(
'wss://api.example.com/graphql',
config: SocketClientConfig(
initialPayload: () async =>
{'Authorization': 'Bearer ${await tokenStorage.read()}'},
),
);
final Link link = Link.split(
(request) => request.isSubscription,
wsLink,
httpLink, // queries + mutations keep using HTTP
);
Consume it with the Subscription widget, which mirrors the Query widget's builder API:
Subscription(
options: SubscriptionOptions(
document: gql(r'''
subscription OnMessage($roomId: ID!) {
messageAdded(roomId: $roomId) {
id
body
sender { name }
}
}
'''),
variables: {'roomId': room.id},
),
builder: (result) {
if (result.hasException) return Text(userMessage(result));
final msg = result.data?['messageAdded'];
if (msg == null) return const SizedBox.shrink();
return ChatBubble(message: msg);
},
);
One gotcha: websockets hold a persistent connection, so create the subscription client once at app start and close it on logout, just like you reset the cache.
When to Go Codegen (Ferry)
graphql_flutter uses Map<String, dynamic> — fast to write, but typos in field names surface at runtime. If your schema is large or your team is growing, ferry generates Dart classes from your schema and documents:
// Generated by ferry — fully typed, autocomplete everywhere.
final usersReq = GGetUsersReq();
You trade some setup complexity (build_runner, schema downloads) for compile-time safety. My rule of thumb: fewer than 30 operations → graphql_flutter; beyond that, evaluate ferry.
Pitfalls to Avoid
-
Storing the client per-screen. One shared
GraphQLClientkeeps the normalized cache consistent. Per-screen clients silently break cache updates. -
Ignoring
hasException. GraphQL returns HTTP 200 even when the query fails — checkresult.exceptionevery time, or errors render as empty screens. - Over-fetching "just in case". The whole point of flutter graphql is precise queries. Ask for what the screen renders, nothing more.
-
Forgetting cache eviction on logout. Call
client.value.resetStore()when the user signs out, or the next user sees the previous user's data.
Closing
Flutter and GraphQL are a natural pair: typed schemas meet a typed language, one query replaces three REST calls, and the normalized cache removes a whole class of state bugs. Start with graphql_flutter, add an AuthLink, keep one shared client, and you have a production-ready data layer in an afternoon.
Need a backend to practice against? GitHub's GraphQL API is free, stable, and rich enough to build a real demo app around. And if your app needs a full GraphQL-backed product built right — from schema design to offline-capable Flutter clients — get in touch and let's scope it.
Originally published at uzairxdev.online
Top comments (0)