Dart GraphQL Complete Guide — Query Data with GraphQL in Dart
In this tutorial, you will learn about Dart GraphQL Complete Guide. We cover key concepts, practical examples, and best practices to help you master this topic.
Dart GraphQL integration using the graphql_flutter package enables type-safe queries and mutations against GraphQL APIs, replacing REST endpoints with a flexible, declarative data-fetching approach that reduces over-fetching and under-fetching.
What You Will Learn
- What GraphQL is and how it differs from REST
- Setting up the graphql_flutter package in a Flutter project
- Writing queries with variables, fragments, and arguments
- Performing mutations to create, update, and delete data
- Managing local cache and optimistic updates
- Handling errors and loading states in the UI
Why It Matters
GraphQL solves fundamental problems with REST APIs. In a RESTful system, you often either get too much data (over-fetching) or have to make multiple requests to get all the data you need (under-fetching). With GraphQL, the client specifies exactly which fields it needs in a single request, and the server responds with only those fields. This reduces bandwidth, simplifies client code, and enables faster iteration when APIs evolve. In Doda Browser, the bookmarks and history sync was migrated from a REST API to GraphQL, reducing payload sizes by 60 percent and eliminating three separate endpoint calls for the settings page.
Real-World Use
A social media feed app needs to display posts with the author name, avatar, post text, image URLs, like count, and the first two comments. With REST, this typically requires three requests: one for posts, one for users, and one for comments. With GraphQL, the client sends one query describing the exact shape of the response, and the server returns all the data in a single response.
Learning Path
flowchart LR A[WebSockets] --> B[GraphQL\nYou are here] B --> C[Project Todo App] style B fill:#f90,color:#fff
Setting Up graphql_flutter
Add the package to your Flutter project:
dependencies:
graphql_flutter: ^5.2.0
Then configure the GraphQL client. The GraphQLClient manages the connection to your GraphQL server, handles Caching, and processes responses.
// lib/services/graphql_service.dart
import 'package:graphql_flutter/graphql_flutter.dart';
class GraphqlService {
late final GraphQLClient _client;
final String endpoint;
GraphqlService(this.endpoint) {
final httpLink = HttpLink(endpoint);
final cache = GraphQLCache(
store: InMemoryStore(),
typePolicies: {
TypePolicy(
typeName: 'Query',
fields: {
'posts': FieldPolicy.keyArgs(['id']),
},
),
},
);
_client = GraphQLClient(
link: httpLink,
cache: cache,
defaultPolicies: DefaultPolicies(
query: Policies(fetch: FetchPolicy.networkFirst),
mutate: Policies(fetch: FetchPolicy.noCache),
),
);
}
GraphQLClient get client => _client;
}
The HttpLink connects to the server. The GraphQLCache with InMemoryStore stores query results locally. The typePolicies configure how the cache normalizes and merges data.
Writing Queries
GraphQL queries request specific fields from the server. Here is a query that fetches posts with related data:
// lib/models/post.dart
class Post {
final String id;
final String title;
final String body;
final String authorName;
final int likeCount;
Post({
required this.id,
required this.title,
required this.body,
required this.authorName,
required this.likeCount,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
authorName: json['author']['name'] as String,
likeCount: json['likeCount'] as int,
);
}
}
// lib/services/post_repository.dart
import 'package:graphql_flutter/graphql_flutter.dart';
import '../models/post.dart';
class PostRepository {
final GraphQLClient client;
PostRepository(this.client);
static const String postsQuery = '''
query GetPosts(\$limit: Int) {
posts(limit: \$limit) {
id
title
body
author {
name
}
likeCount
}
}
''';
Future<List<Post>> fetchPosts({int limit = 10}) async {
final result = await client.query(
QueryOptions(
document: gql(postsQuery),
variables: {'limit': limit},
),
);
if (result.hasException) {
throw result.exception!;
}
final List<dynamic> postsJson = result.data!['posts'] as List<dynamic>;
return postsJson.map((json) => Post.fromJson(json as Map<String, dynamic>)).toList();
}
}
The query uses a variable ($limit) to make it reusable. The server resolves the posts root field and returns only the fields requested: id, title, body, author.name, and likeCount.
Performing Mutations
Mutations change data on the server. Here is a mutation that creates a new post:
class PostRepository {
// ... other methods
static const String createPostMutation = '''
mutation CreatePost(\$title: String!, \$body: String!) {
createPost(title: \$title, body: \$body) {
id
title
body
author {
name
}
likeCount
}
}
''';
Future<Post> createPost({required String title, required String body}) async {
final result = await client.mutate(
MutationOptions(
document: gql(createPostMutation),
variables: {'title': title, 'body': body},
),
);
if (result.hasException) {
throw result.exception!;
}
return Post.fromJson(result.data!['createPost'] as Map<String, dynamic>);
}
static const String deletePostMutation = '''
mutation DeletePost(\$id: ID!) {
deletePost(id: \$id) {
id
}
}
''';
Future<void> deletePost(String id) async {
final result = await client.mutate(
MutationOptions(
document: gql(deletePostMutation),
variables: {'id': id},
),
);
if (result.hasException) {
throw result.exception!;
}
}
}
Mutations follow the same structure as queries but use the mutation keyword. The response typically includes the mutated object so you can update the local cache without another request.
Using GraphQL in Flutter Widgets
The graphql_flutter package provides widgets that handle loading and error states automatically:
// lib/screens/post_list_screen.dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import '../services/post_repository.dart';
import '../services/graphql_service.dart';
class PostListScreen extends StatelessWidget {
final repository = PostRepository(GraphqlService('https://api.example.com/graphql').client);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Posts')),
body: Query(
options: QueryOptions(
document: gql(PostRepository.postsQuery),
variables: {'limit': 20},
),
builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {
if (result.isLoading) {
return Center(child: CircularProgressIndicator());
}
if (result.hasException) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${result.exception}', textAlign: TextAlign.center),
SizedBox(height: 16),
ElevatedButton(onPressed: refetch, child: Text('Retry')),
],
),
);
}
final posts = (result.data!['posts'] as List)
.map((json) => Post.fromJson(json as Map<String, dynamic>))
.toList();
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return Card(
margin: EdgeInsets.all(8),
child: ListTile(
title: Text(post.title),
subtitle: Text('by ${post.authorName} - ${post.likeCount} likes'),
),
);
},
);
},
),
);
}
}
The Query widget automatically runs the query when the widget mounts, rebuilds when data arrives, and provides refetch and fetchMore callbacks.
Optimistic Updates
For a responsive UI, update the cache optimistically before the server confirms the mutation:
Future<Post> createPostOptimistic({required String title, required String body}) async {
final optimisticPost = {
'id': 'temp_${DateTime.now().millisecondsSinceEpoch}',
'title': title,
'body': body,
'author': {'name': 'Current User'},
'likeCount': 0,
};
final result = await client.mutate(
MutationOptions(
document: gql(createPostMutation),
variables: {'title': title, 'body': body},
optimisticResult: {'createPost': optimisticPost},
update: (GraphQLProxyCache cache, QueryResult? result) {
// Update the cache with the new post
final existing = cache.readQuery(gql(postsQuery), variables: {'limit': 10});
if (existing != null && result != null) {
final posts = List<Map<String, dynamic>>.from(existing['posts'] as List);
posts.insert(0, result.data!['createPost'] as Map<String, dynamic>);
cache.writeQuery(gql(postsQuery), variables: {'limit': 10}, data: {'posts': posts});
}
},
),
);
if (result.hasException) throw result.exception!;
return Post.fromJson(result.data!['createPost'] as Map<String, dynamic>);
}
The optimistic result shows immediately in the UI. When the server responds, the real data replaces the placeholder.
Fragments for Reusable Field Sets
GraphQL fragments let you define reusable field selections:
static const String postFields = '''
fragment PostFields on Post {
id
title
body
author {
name
}
likeCount
}
''';
static const String postsWithFragments = '''
query GetPostsWithFragments(\$limit: Int) {
posts(limit: \$limit) {
...PostFields
}
}
$postFields
''';
static const String singlePostWithFragments = '''
query GetPost(\$id: ID!) {
post(id: \$id) {
...PostFields
comments {
id
text
author {
name
}
}
}
}
$postFields
''';
Fragments ensure consistency: if the Post type gains a field, you update it in one place and all queries using the fragment automatically include it.
Common Mistakes
Not handling partial errors: A GraphQL response can have both data and errors. Always check
result.hasExceptioneven whenresult.datais non-null. The server may return partial data with error details for specific fields.Over-fetching in fragments: Including too many fields in a fragment causes over-fetching. Define fragments at the minimum granularity needed by each UI component.
Forgetting to normalize cache: Without type policies, the cache stores query results by query text, which means the same object fetched from different queries is stored twice. Configure
typePolicieswithkeyArgsto normalize by object ID.Ignoring pagination: Many GraphQL APIs use cursor-based pagination. Use the
fetchMoremethod with the cursor from the response to load subsequent pages, rather than re-fetching the entire list.Hard-coding the endpoint URL: GraphQL endpoints differ between environments (development, staging, production). Use environment variables or build configuration to inject the correct URL.
Not testing error states: Network failures, authentication errors, and validation errors all produce different GraphQL exceptions. Write tests that simulate each type and verify the UI response.
Misusing variables in mutations: Mutation variables must match the exact names and types defined in the server schema. A typo in a variable name produces a GraphQL validation error before the mutation executes.
Practice Questions
- How does GraphQL prevent over-fetching compared to REST?
- What is the purpose of an optimistic update in a GraphQL mutation?
- How do GraphQL fragments improve query maintainability?
- What is the difference between a query and a mutation in GraphQL?
- Challenge: Build a GraphQL client that supports paginated comments. Use the
fetchMorepattern to load 10 comments at a time with a "Load more" button. Handle the loading state and prevent duplicate fetches.
Mini Project
Build a movie database app using the TMDB GraphQL API (or use a mock GraphQL server built with package:graphql):
- Configure a
GraphQLClientwith cache normalization - Define queries for popular movies, movie details, and search results
- Implement a search screen with debounced input and paginated results
- Add a detail screen showing movie info, cast, and reviews using fragments
- Implement a "Watchlist" feature using local mutations that update the cache optimistically
- Handle loading, error, and empty states for every screen
- Write integration tests using
MockClientfrom graphql_flutter
FAQ
What is Next
Proceed to Project Todo App to build a complete Flutter application that combines networking, state management, and UI skills. Then check out Project Weather App for API integration patterns.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro