DartFlutter

GraphQL in Flutter Using Hasura and Apollo

GraphQL In Flutter Using HasuraApollo 683x1024

Introduction

Modern Flutter applications demand flexible, efficient data fetching that scales with growing feature sets. Traditional REST APIs often lead to over-fetching or under-fetching data, requiring multiple round trips to gather information for a single screen. GraphQL solves these problems by letting clients request exactly the data they need in a single query. When combined with Hasura for instant backend APIs and Apollo-style clients for state management, Flutter developers gain a powerful toolkit for building responsive, data-driven applications. This guide walks through integrating GraphQL into Flutter applications, leveraging Hasura as a backend, and implementing queries, mutations, and real-time subscriptions.

Why GraphQL for Flutter

GraphQL transforms how mobile applications request and receive data. Instead of calling multiple REST endpoints and filtering responses client-side, GraphQL lets you specify exactly what fields and relationships you need.

Key advantages for Flutter development include:

  • Request only required fields, reducing payload sizes
  • Eliminate over-fetching and under-fetching
  • Strongly typed schemas catch errors at development time
  • Built-in support for real-time updates through subscriptions
  • Single endpoint simplifies API management
  • Self-documenting schemas reduce documentation overhead

For Flutter apps with complex data requirements, GraphQL significantly reduces both network usage and code complexity compared to traditional REST approaches.

Understanding Hasura

Hasura is a GraphQL engine that connects to your PostgreSQL database and instantly generates a production-ready GraphQL API. Instead of writing resolvers and schema definitions manually, Hasura introspects your database and exposes tables, views, and relationships as GraphQL types.

Hasura provides several features essential for mobile backends:

  • Instant GraphQL APIs over PostgreSQL without backend code
  • Built-in filtering, sorting, and pagination
  • Real-time subscriptions via WebSocket connections
  • Role-based access control at the row and column level
  • Event triggers for serverless function integration
  • Remote schemas and actions for custom business logic

For Flutter developers, Hasura dramatically reduces time to market by eliminating backend boilerplate while maintaining flexibility through custom actions and remote schemas.

Core GraphQL Operations

GraphQL defines three operation types that cover most data interaction needs.

Queries

Queries fetch data from the server. They are read-only and cannot modify data:

query GetUsers {
  users {
    id
    name
    email
    profile {
      avatar_url
    }
  }
}

This query retrieves users with their profiles in a single request, avoiding the N+1 problem common with REST APIs.

Mutations

Mutations modify data on the server. They support creating, updating, and deleting records:

mutation CreateUser($name: String!, $email: String!) {
  insert_users_one(object: { name: $name, email: $email }) {
    id
    name
    email
  }
}

Mutations return the modified data, allowing immediate UI updates without additional queries.

Subscriptions

Subscriptions establish persistent connections for real-time updates:

subscription OnNewMessage {
  messages(order_by: { created_at: desc }, limit: 1) {
    id
    content
    sender {
      name
    }
  }
}

When data matching the subscription criteria changes, the server pushes updates to connected clients automatically. This enables live features like chat, notifications, and collaborative editing.

Setting Up GraphQL in Flutter

The graphql_flutter package provides comprehensive GraphQL support for Flutter applications.

Add Dependencies

Add the required packages to your pubspec.yaml:

dependencies:
  graphql_flutter: ^5.1.2

Configure the Client

Create a GraphQL client that handles both HTTP queries and WebSocket subscriptions:

import 'package:graphql_flutter/graphql_flutter.dart';

class GraphQLConfig {
  static final HttpLink httpLink = HttpLink(
    'https://your-hasura-instance.hasura.app/v1/graphql',
  );

  static final WebSocketLink wsLink = WebSocketLink(
    'wss://your-hasura-instance.hasura.app/v1/graphql',
    config: SocketClientConfig(
      autoReconnect: true,
      inactivityTimeout: const Duration(seconds: 30),
    ),
  );

  static Link get link => Link.split(
    (request) => request.isSubscription,
    wsLink,
    httpLink,
  );

  static ValueNotifier<GraphQLClient> get client => ValueNotifier(
    GraphQLClient(
      link: link,
      cache: GraphQLCache(store: HiveStore()),
    ),
  );
}

The Link.split function routes subscription operations through WebSocket while sending queries and mutations over HTTP.

Wrap the Application

Provide the GraphQL client to your widget tree:

void main() async {
  await initHiveForFlutter();
  runApp(
    GraphQLProvider(
      client: GraphQLConfig.client,
      child: const MyApp(),
    ),
  );
}

Executing Queries

The Query widget integrates GraphQL queries directly into Flutter’s reactive UI:

const String getUsersQuery = '''
  query GetUsers {
    users {
      id
      name
      email
    }
  }
''';

class UsersList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Query(
      options: QueryOptions(
        document: gql(getUsersQuery),
        pollInterval: const Duration(minutes: 5),
      ),
      builder: (result, {fetchMore, refetch}) {
        if (result.isLoading && result.data == null) {
          return const Center(child: CircularProgressIndicator());
        }

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

        final users = result.data!['users'] as List;
        return ListView.builder(
          itemCount: users.length,
          itemBuilder: (context, index) {
            final user = users[index];
            return ListTile(
              title: Text(user['name']),
              subtitle: Text(user['email']),
            );
          },
        );
      },
    );
  }
}

The Query widget handles loading states, caching, and error handling automatically. The optional pollInterval parameter enables periodic background refreshes.

Performing Mutations

Mutations modify server data and update the local cache:

const String createUserMutation = '''
  mutation CreateUser($name: String!, $email: String!) {
    insert_users_one(object: { name: $name, email: $email }) {
      id
      name
      email
    }
  }
''';

class CreateUserButton extends StatelessWidget {
  final String name;
  final String email;

  const CreateUserButton({required this.name, required this.email});

  @override
  Widget build(BuildContext context) {
    return Mutation(
      options: MutationOptions(
        document: gql(createUserMutation),
        onCompleted: (data) {
          if (data != null) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('User created successfully')),
            );
          }
        },
        onError: (error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Error: ${error?.message}')),
          );
        },
      ),
      builder: (runMutation, result) {
        return ElevatedButton(
          onPressed: result?.isLoading == true
              ? null
              : () => runMutation({'name': name, 'email': email}),
          child: result?.isLoading == true
              ? const CircularProgressIndicator()
              : const Text('Create User'),
        );
      },
    );
  }
}

Real-Time Subscriptions

Subscriptions provide live data updates without polling:

const String messagesSubscription = '''
  subscription OnMessages {
    messages(order_by: { created_at: desc }) {
      id
      content
      created_at
      sender {
        name
      }
    }
  }
''';

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

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

        final messages = result.data!['messages'] as List;
        return ListView.builder(
          itemCount: messages.length,
          itemBuilder: (context, index) {
            final message = messages[index];
            return ListTile(
              title: Text(message['content']),
              subtitle: Text(message['sender']['name']),
            );
          },
        );
      },
    );
  }
}

The UI updates automatically whenever messages change in the database, enabling features like live chat without additional code.

Authentication with Hasura

Hasura uses JWT tokens for authentication. After users authenticate through your identity provider, include the token in GraphQL requests:

static HttpLink authLink(String token) => HttpLink(
  'https://your-hasura-instance.hasura.app/v1/graphql',
  defaultHeaders: {
    'Authorization': 'Bearer $token',
  },
);

static WebSocketLink authWsLink(String token) => WebSocketLink(
  'wss://your-hasura-instance.hasura.app/v1/graphql',
  config: SocketClientConfig(
    autoReconnect: true,
    initialPayload: () => {
      'headers': {'Authorization': 'Bearer $token'},
    },
  ),
);

Configure Hasura permissions to enforce access control based on JWT claims. Each role defines what tables, columns, and rows users can access.

Caching Strategies

Effective caching improves performance and enables offline functionality:

  • Use normalized caching to deduplicate entities across queries
  • Configure fetch policies based on data freshness requirements
  • Leverage optimistic updates for responsive UIs
  • Persist cache to disk for offline support
QueryOptions(
  document: gql(query),
  fetchPolicy: FetchPolicy.cacheFirst, // Use cache, fallback to network
  // Or FetchPolicy.networkOnly for always fresh data
  // Or FetchPolicy.cacheAndNetwork for immediate cache then update
)

Error Handling Best Practices

Robust error handling improves user experience:

builder: (result, {fetchMore, refetch}) {
  if (result.hasException) {
    final exception = result.exception!;
    
    if (exception.linkException != null) {
      // Network error
      return RetryWidget(onRetry: refetch);
    }
    
    if (exception.graphqlErrors.isNotEmpty) {
      // GraphQL errors (validation, permissions, etc.)
      return ErrorWidget(errors: exception.graphqlErrors);
    }
  }
  // Handle success...
}

Common Pitfalls

Over-fetching in Queries

Request only the fields you need. Large queries slow down both the backend and UI rendering.

Ignoring Permissions

Always configure Hasura permissions properly. Without them, your API exposes all data to all users.

Missing Error Handling

Always handle loading and error states. Silent failures create confusing user experiences.

Subscription Memory Leaks

Subscriptions maintain persistent connections. Ensure they are properly disposed when widgets unmount.

Conclusion

GraphQL with Hasura and Flutter creates a powerful, efficient data layer for mobile applications. The combination of precise queries, instant backend APIs, and real-time subscriptions enables features that would require significant backend development with traditional REST approaches. By following the patterns in this guide, you can build Flutter applications that scale gracefully while remaining maintainable and performant.

For offline data persistence, read Building Offline-First Flutter Apps: Local Storage and Sync. To understand API design patterns, see REST vs GraphQL vs gRPC. For state management options, explore Flutter State Management: Riverpod vs BLoC. You can also visit the Hasura documentation and GraphQL official documentation for additional resources.

1 Comment

Leave a Comment