Handling Errors and Loading States When Making GraphQL Requests in Flutter

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 the graphql_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 or sentry_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.

Leave a Reply

Your email address will not be published. Required fields are marked *