Handling Errors and Loading States in GraphQL Requests in Flutter

GraphQL has emerged as a powerful alternative to traditional REST APIs, offering greater flexibility and efficiency in data fetching. In Flutter, implementing GraphQL can significantly enhance your app’s performance and user experience. However, effectively handling errors and loading states is crucial for robust and user-friendly applications. This post will guide you through handling errors and loading states in GraphQL requests in Flutter, providing practical code examples.

What is GraphQL?

GraphQL is a query language for APIs and a server-side runtime for executing those queries by using a type system you define for your data. Unlike REST, which often requires fetching data from multiple endpoints, GraphQL allows you to ask for exactly what you need and get it in a single request.

Why Handle Errors and Loading States?

  • Improved User Experience: Providing feedback during loading times and gracefully handling errors prevents frustration and confusion.
  • Robust Applications: Properly handling errors ensures your app doesn’t crash or display incorrect data.
  • Better Debugging: Effective error handling makes it easier to identify and fix issues in your GraphQL implementation.

Setting up GraphQL in Flutter

Before diving into error and loading state management, let’s set up a basic GraphQL client in Flutter.

Step 1: Add Dependencies

Add the graphql_flutter package to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  graphql_flutter: ^5.1.0

Run flutter pub get to install the dependencies.

Step 2: Initialize the GraphQL Client

Create a GraphQL client using GraphQLClient:

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

void main() {
  final HttpLink httpLink = HttpLink(
    'https://your-graphql-endpoint/graphql',
  );

  final GraphQLClient client = GraphQLClient(
    link: httpLink,
    cache: GraphQLCache(store: HiveStore()),
  );

  runApp(
    GraphQLProvider(
      client: ValueNotifier(client),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GraphQL Flutter App',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('GraphQL Example'),
        ),
        body: const MyGraphQLWidget(),
      ),
    );
  }
}

Handling Loading States

During GraphQL requests, it’s crucial to display a loading indicator to inform the user that data is being fetched.

Using Query Widget

The graphql_flutter package provides a Query widget to handle GraphQL queries. It automatically manages loading and error states.

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

class MyGraphQLWidget extends StatelessWidget {
  const MyGraphQLWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    const String readRepositories = """
      query ReadRepositories($nRepositories: Int!) {
        viewer {
          repositories(last: $nRepositories) {
            nodes {
              id
              name
              description
            }
          }
        }
      }
    """;

    return Query(
      options: QueryOptions(
        document: gql(readRepositories),
        variables: {
          'nRepositories': 10,
        },
      ),
      builder: (
        QueryResult result, {
        Refetch? refetch,
        FetchMore? fetchMore,
      }) {
        if (result.hasException) {
          return Text('Error: ${result.exception.toString()}');
        }

        if (result.isLoading) {
          return const Center(child: CircularProgressIndicator());
        }

        final 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 this example:

  • The Query widget automatically rebuilds its UI based on the query state.
  • When result.isLoading is true, a CircularProgressIndicator is displayed.

Handling Errors

Gracefully handling errors is essential for a smooth user experience. The Query widget also helps manage error states.

Error Handling with Query Widget

In the same example, errors are handled using result.hasException:

if (result.hasException) {
    return Text('Error: ${result.exception.toString()}');
}

This code checks if the GraphQL request resulted in an exception and displays an error message.

Detailed Error Information

To provide more detailed error information, you can inspect the result.exception object.

if (result.hasException) {
  final exception = result.exception!;
  return Column(
    children: [
      Text('Error: ${exception.toString()}'),
      if (exception.graphqlErrors.isNotEmpty)
        Column(
          children: exception.graphqlErrors.map((e) => Text('GraphQL Error: ${e.message}')).toList(),
        ),
      if (exception.clientException != null)
        Text('Client Error: ${exception.clientException.toString()}'),
    ],
  );
}

Here, we check for both GraphQL errors and client-side exceptions to provide comprehensive error information.

Handling Loading and Errors with Mutations

Mutations are used to modify data on the server. Handling loading and error states for mutations is similar to queries but uses the Mutation widget.

Using Mutation Widget

Here’s an example of how to use the Mutation widget:

import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

class AddTodoMutation extends StatefulWidget {
  const AddTodoMutation({Key? key}) : super(key: key);

  @override
  _AddTodoMutationState createState() => _AddTodoMutationState();
}

class _AddTodoMutationState extends State {
  final TextEditingController _textController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    const String addTodo = """
      mutation AddTodo($title: String!) {
        createTodo(input: { title: $title, userId: "1" }) {
          id
          title
        }
      }
    """;

    return Mutation(
      options: MutationOptions(
        document: gql(addTodo),
        onCompleted: (dynamic result) {
          // Handle completion
          print('Todo added: ${result.toString()}');
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('Todo added successfully!')),
          );
        },
        onError: (error) {
          // Handle error
          print('Error adding todo: ${error.toString()}');
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Error adding todo: ${error.toString()}')),
          );
        },
      ),
      builder: (
        RunMutation runMutation,
        QueryResult? result,
      ) {
        return Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            children: [
              TextField(
                controller: _textController,
                decoration: const InputDecoration(labelText: 'Enter Todo'),
              ),
              ElevatedButton(
                onPressed: () {
                  runMutation({
                    'title': _textController.text,
                  });
                },
                child: const Text('Add Todo'),
              ),
              if (result != null && result.isLoading)
                const Center(child: CircularProgressIndicator()),
            ],
          ),
        );
      },
    );
  }
}

Key aspects of this example:

  • The Mutation widget provides a builder function that exposes the runMutation function to trigger the mutation.
  • result.isLoading is used to show a loading indicator while the mutation is in progress.
  • onCompleted and onError callbacks are used to handle the success or failure of the mutation, respectively.

Custom Error Handling

For more advanced error handling, you can implement custom logic using try-catch blocks or custom error classes.

Example: Custom Error Handling with try-catch

Future performGraphQLRequest(GraphQLClient client, String query, Map variables) async {
  try {
    final QueryResult result = await client.query(
      QueryOptions(
        document: gql(query),
        variables: variables,
      ),
    );

    if (result.hasException) {
      throw result.exception!;
    }

    return result;
  } catch (e) {
    print('Error performing GraphQL request: ${e.toString()}');
    rethrow; // Re-throw the error to be handled by the caller
  }
}

Best Practices for Error and Loading State Management

  • Display Clear Loading Indicators: Use progress bars, spinners, or skeleton loaders to indicate data fetching.
  • Provide Meaningful Error Messages: Inform the user about the error and suggest possible solutions.
  • Log Errors: Use logging tools to track errors for debugging and monitoring.
  • Handle Edge Cases: Consider network issues, server downtime, and invalid data.

Conclusion

Handling errors and loading states in GraphQL requests in Flutter is crucial for creating robust and user-friendly applications. By leveraging the graphql_flutter package, you can easily manage these states with the Query and Mutation widgets. Implementing custom error handling and following best practices will further enhance your app’s reliability and user experience. Properly addressing these aspects ensures your GraphQL implementation is seamless and effective.