Back to Posts

Using GraphQL in Flutter Apps

18 min read

GraphQL offers a flexible and efficient way to fetch and manage data in Flutter applications. This comprehensive guide covers everything from basic setup to advanced features like subscriptions and caching.

Setting Up GraphQL in Flutter

1. Package Installation

dependencies:
  flutter:
    sdk: flutter
  graphql_flutter: ^5.0.0
  hive: ^2.0.0
  hive_flutter: ^1.0.0

2. Client Configuration

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  await Hive.openBox('graphql_cache');
  
  final HttpLink httpLink = HttpLink(
    'https://your-graphql-endpoint.com/graphql',
    defaultHeaders: {
      'Authorization': 'Bearer your-token',
    },
  );

  final AuthLink authLink = AuthLink(
    getToken: () async => 'Bearer your-token',
  );

  final Link link = authLink.concat(httpLink);

  final ValueNotifier<GraphQLClient> client = ValueNotifier(
    GraphQLClient(
      link: link,
      cache: GraphQLCache(store: HiveStore()),
      defaultPolicies: DefaultPolicies(
        query: Policies(
          fetch: FetchPolicy.networkOnly,
          error: ErrorPolicy.none,
          cacheReread: CacheRereadPolicy.mergeOptimistic,
        ),
        mutate: Policies(
          fetch: FetchPolicy.networkOnly,
          error: ErrorPolicy.none,
          cacheReread: CacheRereadPolicy.mergeOptimistic,
        ),
        subscribe: Policies(
          fetch: FetchPolicy.networkOnly,
          error: ErrorPolicy.none,
          cacheReread: CacheRereadPolicy.mergeOptimistic,
        ),
      ),
    ),
  );

  runApp(MyApp(client: client));
}

Writing Queries and Mutations

1. Query Implementation

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

  static const String getUsers = r'''
    query GetUsers($first: Int, $after: String) {
      users(first: $first, after: $after) {
        edges {
          node {
            id
            name
            email
            posts {
              id
              title
            }
          }
          cursor
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  ''';

  @override
  Widget build(BuildContext context) {
    return Query(
      options: QueryOptions(
        document: gql(getUsers),
        variables: {
          'first': 10,
          'after': null,
        },
        pollInterval: const Duration(seconds: 10),
      ),
      builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {
        if (result.isLoading) {
          return const Center(child: CircularProgressIndicator());
        }

        if (result.hasException) {
          return ErrorWidget(result.exception!);
        }

        final users = result.data?['users']?['edges'] ?? [];
        final pageInfo = result.data?['users']?['pageInfo'];

        return ListView.builder(
          itemCount: users.length + (pageInfo['hasNextPage'] ? 1 : 0),
          itemBuilder: (context, index) {
            if (index == users.length) {
              return ElevatedButton(
                onPressed: () {
                  fetchMore!(
                    FetchMoreOptions(
                      variables: {
                        'after': pageInfo['endCursor'],
                      },
                      updateQuery: (previous, next) {
                        final List<dynamic> repos = [
                          ...previous?['users']?['edges'] ?? [],
                          ...next?['users']?['edges'] ?? [],
                        ];
                        next?['users']?['edges'] = repos;
                        return next;
                      },
                    ),
                  );
                },
                child: const Text('Load More'),
              );
            }

            final user = users[index]['node'];
            return ListTile(
              title: Text(user['name']),
              subtitle: Text(user['email']),
              trailing: Text('${user['posts']?.length ?? 0} posts'),
            );
          },
        );
      },
    );
  }
}

2. Mutation Implementation

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

  static const String addUser = r'''
    mutation AddUser($input: CreateUserInput!) {
      addUser(input: $input) {
        id
        name
        email
        posts {
          id
          title
        }
      }
    }
  ''';

  @override
  Widget build(BuildContext context) {
    return Mutation(
      options: MutationOptions(
        document: gql(addUser),
        update: (GraphQLDataProxy cache, QueryResult? result) {
          if (result?.data != null) {
            final newUser = result!.data!['addUser'];
            final normalizedId = cache.identify(newUser)!;
            cache.writeQuery(
              QueryOptions(
                document: gql(getUsers),
              ),
              data: {
                'users': {
                  'edges': [
                    {
                      'node': newUser,
                      'cursor': normalizedId,
                    },
                  ],
                },
              },
            );
          }
        },
        onCompleted: (dynamic resultData) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('User added successfully')),
          );
        },
        onError: (OperationException? error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(error.toString())),
          );
        },
      ),
      builder: (RunMutation runMutation, QueryResult? result) {
        final formKey = GlobalKey<FormState>();
        final nameController = TextEditingController();
        final emailController = TextEditingController();

        return Form(
          key: formKey,
          child: Column(
            children: [
              TextFormField(
                controller: nameController,
                decoration: const InputDecoration(labelText: 'Name'),
                validator: (value) {
                  if (value?.isEmpty ?? true) {
                    return 'Please enter a name';
                  }
                  return null;
                },
              ),
              TextFormField(
                controller: emailController,
                decoration: const InputDecoration(labelText: 'Email'),
                validator: (value) {
                  if (value?.isEmpty ?? true) {
                    return 'Please enter an email';
                  }
                  return null;
                },
              ),
              ElevatedButton(
                onPressed: () {
                  if (formKey.currentState?.validate() ?? false) {
                    runMutation({
                      'input': {
                        'name': nameController.text,
                        'email': emailController.text,
                      },
                    });
                  }
                },
                child: const Text('Add User'),
              ),
            ],
          ),
        );
      },
    );
  }
}

Implementing Subscriptions

1. WebSocket Setup

final WebSocketLink websocketLink = WebSocketLink(
  url: 'wss://your-graphql-endpoint.com/graphql',
  config: SocketClientConfig(
    autoReconnect: true,
    inactivityTimeout: const Duration(seconds: 30),
    initialPayload: () async => {
      'headers': {
        'Authorization': 'Bearer your-token',
      },
    },
  ),
);

final Link link = Link.split(
  (request) => request.isSubscription,
  websocketLink,
  httpLink,
);

2. Subscription Implementation

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

  static const String userUpdates = r'''
    subscription OnUserUpdate {
      userUpdates {
        id
        name
        email
        status
      }
    }
  ''';

  @override
  Widget build(BuildContext context) {
    return Subscription(
      options: SubscriptionOptions(
        document: gql(userUpdates),
      ),
      builder: (QueryResult result) {
        if (result.isLoading) {
          return const Center(child: CircularProgressIndicator());
        }

        if (result.hasException) {
          return ErrorWidget(result.exception!);
        }

        final updates = result.data?['userUpdates'] ?? [];
        return ListView.builder(
          itemCount: updates.length,
          itemBuilder: (context, index) {
            final user = updates[index];
            return ListTile(
              title: Text(user['name']),
              subtitle: Text(user['email']),
              trailing: Text(user['status']),
            );
          },
        );
      },
    );
  }
}

Error Handling and Retry Logic

1. Error Handling

class GraphQLErrorHandler {
  static void handleError(BuildContext context, OperationException? error) {
    if (error == null) return;

    final message = error.graphqlErrors.isNotEmpty
        ? error.graphqlErrors.first.message
        : error.linkException?.toString() ?? 'An error occurred';

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        action: SnackBarAction(
          label: 'Retry',
          onPressed: () {
            // Implement retry logic
          },
        ),
      ),
    );
  }
}

class RetryLink extends Link {
  final int maxRetries;
  final Duration retryInterval;

  RetryLink({
    this.maxRetries = 3,
    this.retryInterval = const Duration(seconds: 1),
  });

  @override
  Stream<Response> request(Request request, [NextLink? forward]) async* {
    int attempts = 0;
    while (true) {
      try {
        yield* forward!(request);
        break;
      } catch (e) {
        attempts++;
        if (attempts >= maxRetries) rethrow;
        await Future.delayed(retryInterval);
      }
    }
  }
}

2. Cache Management

class CacheManager {
  static Future<void> clearCache() async {
    final box = await Hive.openBox('graphql_cache');
    await box.clear();
  }

  static Future<void> updateCache(String query, Map<String, dynamic> data) async {
    final box = await Hive.openBox('graphql_cache');
    await box.put(query, data);
  }

  static Future<Map<String, dynamic>?> getFromCache(String query) async {
    final box = await Hive.openBox('graphql_cache');
    return box.get(query);
  }
}

Best Practices

1. Code Organization

  • Separate queries, mutations, and subscriptions into different files
  • Use fragments for reusable fields
  • Implement proper error handling
  • Use type-safe operations
  • Implement proper caching strategies

2. Performance Optimization

  • Use pagination for large datasets
  • Implement proper caching
  • Use optimistic updates
  • Minimize network requests
  • Implement proper error handling

3. Testing

  • Test queries and mutations
  • Test error handling
  • Test caching behavior
  • Test subscription functionality
  • Test performance

Conclusion

Implementing GraphQL in Flutter requires:

  • Proper setup and configuration
  • Understanding of queries, mutations, and subscriptions
  • Implementation of error handling
  • Proper caching strategies
  • Following best practices

Remember to:

  • Organize your code properly
  • Handle errors gracefully
  • Implement proper caching
  • Test thoroughly
  • Monitor performance

By following these guidelines, you can effectively implement GraphQL in your Flutter applications.