When building Flutter applications that rely on data fetched from a GraphQL API, it’s essential to handle errors gracefully and provide visual cues to the user during loading states. Properly managing these aspects significantly improves the user experience by keeping the UI responsive and informative. In this comprehensive guide, we’ll explore best practices for handling errors and loading states when making GraphQL requests in Flutter.
Why Error and Loading State Handling Matters
Users expect applications to provide feedback on ongoing processes. Without proper error handling and loading state indicators, the app may appear unresponsive or broken. Effectively managing these states provides several benefits:
- Improved User Experience: Visual feedback assures users the app is working and keeps them informed of the progress.
- Clarity in Case of Failure: Users understand why data isn’t loading, enabling them to take corrective actions like checking their network connection or retrying.
- Preventing App Instability: Gracefully handling errors prevents unexpected crashes and broken UIs.
Prerequisites
Before diving into the implementation, ensure you have the following:
- Flutter SDK installed.
- An active GraphQL API endpoint.
- Basic knowledge of Flutter and GraphQL.
graphql_flutter
package added to your project’s dependencies.
Installing the graphql_flutter
Package
To add the graphql_flutter
package to your Flutter project, add the following line to your pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
graphql_flutter: ^5.1.0 # Use the latest version
Then, run flutter pub get
in your terminal to install the dependencies.
Making GraphQL Requests
Before addressing error and loading state handling, let’s look at how to make GraphQL requests using the graphql_flutter
package.
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
const String githubApiUrl = 'https://api.github.com/graphql'; // Example public GraphQL API
void main() {
final HttpLink httpLink = HttpLink(githubApiUrl);
final ValueNotifier<GraphQLClient> client = ValueNotifier(
GraphQLClient(
link: httpLink,
cache: GraphQLCache(store: HiveStore()),
),
);
var app = GraphQLProvider(
client: client,
child: MyApp(),
);
runApp(app);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'GraphQL Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const String query = r'''
query ReadRepositories {
viewer {
repositories(first: 10) {
nodes {
name
description
}
}
}
}
''';
return Scaffold(
appBar: AppBar(
title: const Text('GraphQL GitHub Repos'),
),
body: Query(
options: QueryOptions(document: gql(query)),
builder: (QueryResult result, {Refetch<Object?>? refetch, FetchMore<Object?>? fetchMore}) {
if (result.hasException) {
return Text(result.exception.toString());
}
if (result.isLoading) {
return const Center(child: CircularProgressIndicator());
}
List? repositories = result.data?['viewer']['repositories']['nodes'];
if (repositories == null) {
return const Text('No repositories found');
}
return ListView.builder(
itemCount: repositories.length,
itemBuilder: (context, index) {
final repository = repositories[index];
return ListTile(
title: Text(repository['name'] ?? 'No Name'),
subtitle: Text(repository['description'] ?? 'No Description'),
);
},
);
},
),
);
}
}
In the example above, a GraphQL query is made to fetch repository data from the GitHub GraphQL API. Here’s a breakdown:
- The GraphQL client is initialized with an
HttpLink
pointing to the API endpoint. - A
Query
widget from thegraphql_flutter
package is used to execute the GraphQL query. - The
builder
function returns different widgets based on the state of the query (loading, error, data).
Handling Loading States
A loading state is essential for indicating that data is being fetched. The graphql_flutter
package simplifies this with the isLoading
property of the QueryResult
.
Displaying a Loading Indicator
Use the isLoading
property to display a loading indicator. For example:
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const String query = r'''
query ReadRepositories {
viewer {
repositories(first: 10) {
nodes {
name
description
}
}
}
}
''';
return Scaffold(
appBar: AppBar(
title: const Text('GraphQL GitHub Repos'),
),
body: Query(
options: QueryOptions(document: gql(query)),
builder: (QueryResult result, {Refetch<Object?>? refetch, FetchMore<Object?>? fetchMore}) {
if (result.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Remaining logic
return const Text("Loaded!");
},
),
);
}
}
Here, a CircularProgressIndicator
is displayed in the center of the screen while result.isLoading
is true
.
Custom Loading Indicators
You can customize the loading indicator based on your app’s design. Instead of a simple progress indicator, you might show a custom animated loading screen.
Widget buildLoadingIndicator() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 10),
Text(
'Fetching Data...',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
);
}
Usage in the Query
builder:
builder: (QueryResult result, {Refetch<Object?>? refetch, FetchMore<Object?>? fetchMore}) {
if (result.isLoading) {
return Center(child: buildLoadingIndicator());
}
}
Handling Errors
Error handling is vital to providing a user-friendly experience when a GraphQL request fails. The graphql_flutter
package provides an hasException
property on the QueryResult
, along with an exception
property containing details about the error.
Displaying Error Messages
Check result.hasException
to handle errors and display a user-friendly message. For example:
builder: (QueryResult result, {Refetch<Object?>? refetch, FetchMore<Object?>? fetchMore}) {
if (result.hasException) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 40),
const SizedBox(height: 10),
Text(
'An error occurred: ${result.exception}',
style: const TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
],
),
);
}
if (result.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Data display logic
return const Text("Data Loaded Successfully!");
}
In this example, if an exception is present, an error icon and a description of the error are displayed to the user.
Specific Error Handling
Sometimes, it’s useful to handle different types of errors uniquely. You can inspect the exception
to handle specific cases:
builder: (QueryResult result, {Refetch<Object?>? refetch, FetchMore<Object?>? fetchMore}) {
if (result.hasException) {
final exception = result.exception;
if (exception is NetworkException) {
// Handle network-related errors
return const Center(
child: Text('Network error occurred. Please check your connection.'),
);
} else if (exception is OperationException) {
// Handle GraphQL operation-specific errors
return Center(
child: Text('GraphQL error: ${exception.graphqlErrors}'),
);
} else {
// Handle other types of errors
return const Center(child: Text('An unexpected error occurred.'));
}
}
if (result.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Data display logic
return const Text("Data Loaded Successfully!");
}
Refetching Data on Error
When an error occurs, it may be helpful to allow the user to retry the request. Provide a button that calls the refetch
function, which re-executes the query.
builder: (QueryResult result, {Refetch<Object?>? refetch, FetchMore<Object?>? fetchMore}) {
if (result.hasException) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('An error occurred while fetching data.'),
ElevatedButton(
onPressed: () {
refetch!();
},
child: const Text('Retry'),
),
],
),
);
}
if (result.isLoading) {
return const Center(child: CircularProgressIndicator());
}
// Data display logic
return const Text("Data Loaded Successfully!");
}
The refetch!()
call will rerun the GraphQL query, giving the user a chance to recover from temporary issues such as network hiccups.
Using StreamBuilder
for Real-Time Updates
For applications needing real-time data, GraphQL subscriptions can be implemented using the StreamBuilder
widget in Flutter. Here’s how to manage loading and error states with streams.
Implementing a GraphQL Subscription
First, create a stream that listens to GraphQL subscription events:
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'dart:async';
class GraphQLSubscriptionWidget extends StatefulWidget {
const GraphQLSubscriptionWidget({Key? key}) : super(key: key);
@override
_GraphQLSubscriptionWidgetState createState() => _GraphQLSubscriptionWidgetState();
}
class _GraphQLSubscriptionWidgetState extends State<GraphQLSubscriptionWidget> {
late StreamController<QueryResult> _streamController;
late Stream<QueryResult> _stream;
late GraphQLClient _client;
@override
void initState() {
super.initState();
_streamController = StreamController<QueryResult>.broadcast();
_stream = _streamController.stream;
final HttpLink httpLink = HttpLink('YOUR_GRAPHQL_ENDPOINT');
final WebSocketLink websocketLink = WebSocketLink(
'YOUR_GRAPHQL_WS_ENDPOINT',
config: const SocketClientConfig(
autoReconnect: true,
inactivityTimeout: Duration(seconds: 30),
),
);
final Link link = httpLink.concat(websocketLink);
_client = GraphQLClient(
link: link,
cache: GraphQLCache(store: HiveStore()),
);
// Execute the subscription query
const String subscriptionQuery = r'''
subscription OnCommentAdded($repoFullName: String!) {
commentAdded(repoFullName: $repoFullName) {
id
content
}
}
''';
final SubscriptionOptions options = SubscriptionOptions(
document: gql(subscriptionQuery),
variables: const {
'repoFullName': 'flutter/flutter', // Replace with your desired repo
},
);
final Stream<QueryResult> queryStream = _client.subscribe(options);
queryStream.listen((QueryResult result) {
_streamController.add(result);
});
}
@override
void dispose() {
_streamController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('GraphQL Subscription Demo'),
),
body: StreamBuilder<QueryResult>(
stream: _stream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
if (!snapshot.hasData || snapshot.data!.hasException) {
return Center(child: Text('GraphQL Error: ${snapshot.data?.exception}'));
}
final comment = snapshot.data?.data?['commentAdded'];
return Center(
child: Text('New comment: ${comment?['content'] ?? 'No comment'}'),
);
},
),
);
}
}
Handling States in StreamBuilder
The StreamBuilder
automatically handles different connection states, such as:
ConnectionState.waiting
: Indicates data is loading.snapshot.hasError
: Indicates an error occurred during stream processing.snapshot.hasData
: Indicates data is available from the stream.
By inspecting these states, you can display appropriate indicators or error messages.
Advanced Tips
- Logging Errors: Implement robust error logging using packages like
logger
orsentry_flutter
to track issues in production. - Caching: Use GraphQL caching effectively (with packages like
graphql_flutter
‘s built-in caching) to minimize unnecessary network requests and improve the perceived performance of your app. - Debouncing Refetch: When allowing users to retry, debounce the refetch function to prevent multiple rapid retries that could overwhelm the API.
- Graceful Degradation: Design your app to provide partial functionality even when some data sources are unavailable due to errors.
Conclusion
Handling errors and loading states is crucial for providing a reliable and user-friendly experience in Flutter apps consuming GraphQL APIs. Using graphql_flutter
, you can easily display loading indicators, error messages, and even provide retry mechanisms to gracefully handle potential issues. These best practices help ensure your users remain engaged and informed, regardless of network conditions or API reliability. Additionally, using StreamBuilder
with GraphQL subscriptions allows you to handle real-time updates effectively, making your app more dynamic and responsive.