GraphQL #

When designing your app’s network communication architecture, REST APIs are often the first choice that comes to mind. However, as your app’s data complexity grows, you’ll start facing various efficiency challenges with REST APIs. For example, the need to make several API hits to different endpoints just to draw one screen page (under-fetching), or receiving very large response payloads containing properties you don’t actually need in the UI (over-fetching).

To overcome these limitations, the industry introduced GraphQL. GraphQL is a query language for APIs and a runtime for executing those queries using the type system you define for your data. Unlike REST APIs where the backend server determines the response structure absolutely, GraphQL gives full control to the client (your Flutter app) to request data dynamically. You determine precisely which fields you need — no less, and no more.

In this guide document, we’ll break down GraphQL’s main concepts, learn multi-link client configuration (combining HTTP and WebSockets), use built-in declarative widgets, and integrate imperative GraphQL calls inside Riverpod state management.

Key GraphQL Concepts #

Before diving into the technical setup in Flutter, you must understand the three main pillars of operations in the GraphQL language:

  1. Query: The operation to read or fetch data from the server. In REST APIs, Query is equivalent to the GET method.
  2. Mutation: The operation to modify or write data on the server (like creating new data, updating data, or deleting data). In REST APIs, Mutation is equivalent to the POST, PUT, PATCH, and DELETE methods.
  3. Subscription: A real-time operation utilizing a two-way connection (persistent connection) using the WebSockets protocol. The server actively emits data to the client every time certain data changes in the backend.

Here are examples of writing GraphQL documents for each of those operations:

# 1. QUERY EXAMPLE: Fetching specific product info
query GetProductDetail($id: ID!) {
  product(id: $id) {
    id
    name
    price
    # We only request name & price, other properties won't be sent by the server
  }
}

# 2. MUTATION EXAMPLE: Adding an item to the shopping cart
mutation AddToCart($productId: ID!, $qty: Int!) {
  addItem(productId: $productId, qty: $qty) {
    successStatus
    responseMessage
    cart {
      totalPrice
    }
  }
}

# 3. SUBSCRIPTION EXAMPLE: Monitoring courier order status updates in real-time
subscription TrackCourier($orderId: ID!) {
  courierStatusUpdate(orderId: $orderId) {
    latitude
    longitude
    deliveryStatus
  }
}

REST API vs GraphQL Comparison #

Let’s visualize the fundamental difference in how data is exchanged between REST APIs and GraphQL to understand why GraphQL is so efficient at minimizing device internet quota consumption:

graph TD
    classDef default stroke:#333,stroke-width:2px;
    
    subgraph REST_API["REST API (Multiple Endpoints)"]
        R1["GET /products/123"] -->|1. Request| S1["REST Server"]
        S1 -->|Returns full product| R1
        R2["GET /products/123/reviews"] -->|2. Request| S1
        S1 -->|Returns review list| R2
        R3["GET /products/123/seller"] -->|3. Request| S1
        S1 -->|Returns seller profile| R3
    end
    
    subgraph GraphQL_API["GraphQL API (Single Endpoint)"]
        G1["POST /graphql (Specific query)"] -->|1. Single Request| S2["GraphQL Server"]
        S2 -->|Returns filtered data| G1
    end

With REST APIs, you’re forced to make 3 requests to 3 different endpoints to display product info, reviews, and seller details. With GraphQL, you just send 1 request containing a combined query structure to the /graphql endpoint, and the server replies with a unified data structure exactly as you requested in a single network transaction.


Installing the GraphQL Ecosystem in Flutter #

To use GraphQL in Flutter, the community provides the graphql_flutter library. This library comes with a Hive-based cache management system, link interceptors, and built-in declarative widgets.

Register its dependency in your project’s pubspec.yaml file:

dependencies:
  # The main GraphQL Flutter library
  graphql_flutter: ^5.2.0-beta.7

Comprehensive GraphQL Client Configuration #

In GraphQL, all communication route configurations are managed using a link chain system (Link Chain). You can combine an HTTP link (HttpLink) to handle regular Query/Mutation and a WebSockets link (WebSocketLink) to handle real-time Subscriptions.

You also use AuthLink to automatically inject security tokens into every request header.

Here’s the complete, production-safe GraphQLClient initialization code:

// core/network/graphql_client_config.dart
import 'package:graphql_flutter/graphql_flutter.dart';
import '../../core/auth/token_storage.dart';

class GraphQLClientConfig {
  static GraphQLClient initializeClient() {
    // 1. Authentication Link to inject the jwt token into request headers
    final authLink = AuthLink(
      getToken: () async {
        final token = await TokenStorage.getAccessToken();
        return token != null ? 'Bearer $token' : null;
      },
    );

    // 2. HTTP Link to handle regular Queries & Mutations
    final httpLink = HttpLink('https://api.ourstore.com/graphql');

    // 3. WebSocket Link to handle real-time Subscriptions
    final wsLink = WebSocketLink(
      'wss://api.ourstore.com/graphql',
      config: SocketClientConfig(
        autoReconnect: true, // Automatically reconnect if the connection drops
        inactivityTimeout: const Duration(seconds: 30),
        initialPayload: () async {
          // Send the auth token during the initial websocket handshake
          final token = await TokenStorage.getAccessToken();
          return {'Authorization': 'Bearer $token'};
        },
      ),
    );

    // 4. Automatically split request paths:
    // If the request is a subscription type, flow it to WebSocketLink. Otherwise, flow it to HttpLink.
    final combinedLink = Link.split(
      (request) => request.isSubscription,
      wsLink,
      httpLink,
    );

    // 5. Chain the auth link in front of the main communication link
    final fullLinkChain = authLink.concat(combinedLink);

    return GraphQLClient(
      link: fullLinkChain,
      // Use HiveStore as the persistent local cache database
      cache: GraphQLCache(store: HiveStore()),
      defaultPolicies: DefaultPolicies(
        query: Policies(
          // Standard cache policy: load from local cache first, then update in the background
          fetch: FetchPolicy.cacheAndNetwork,
        ),
        watchQuery: Policies(
          fetch: FetchPolicy.cacheAndNetwork,
        ),
        mutate: Policies(
          // Data mutations must always be fired directly to the API network server
          fetch: FetchPolicy.networkOnly,
        ),
      ),
    );
  }
}

Initializing and Registering GraphQLProvider #

For widgets in your app to use the same client instance, you must initialize the local Hive cache storage and wrap MaterialApp with the GraphQLProvider widget in your main.dart file.

// main.dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'core/network/graphql_client_config.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Must be initialized to prepare the local Hive database for the GraphQL cache
  await initHiveForFlutter();

  final graphQLClient = GraphQLClientConfig.initializeClient();

  runApp(
    GraphQLProvider(
      // Wrap the instance with a ValueNotifier so it's responsive to status changes
      client: ValueNotifier(graphQLClient),
      child: const OurApp(),
    ),
  );
}

class OurApp extends StatelessWidget {
  const OurApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(body: Center(child: Text('GraphQL Ready to Use'))),
    );
  }
}

Reading Data with the Query Widget #

graphql_flutter provides a declarative builder widget called Query for reading data from the server. This widget handles the data fetching lifecycle, loading states, error states, cache logging, and pagination in an integrated way.

Here’s a product list screen implementation leveraging the fetchMore pagination feature:

// presentation/screens/product_list_screen.dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import '../../domain/entities/product.dart';

// Writing the query as a multi-line string
const String _fetchProductsQuery = r'''
  query FetchProductList($category: String, $page: Int) {
    productList(category: $category, page: $page) {
      items {
        id
        name
        price
        thumbnail
      }
      hasNextPage
    }
  }
''';

class GraphQLProductListScreen extends StatelessWidget {
  final String? categoryFilter;

  const GraphQLProductListScreen({super.key, this.categoryFilter});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('GraphQL Product List')),
      body: Query(
        options: QueryOptions(
          document: gql(_fetchProductsQuery),
          variables: {
            'category': categoryFilter,
            'page': 1,
          },
          fetchPolicy: FetchPolicy.cacheAndNetwork,
        ),
        builder: (QueryResult result, {VoidCallback? refetch, FetchMore? fetchMore}) {
          // 1. Error condition
          if (result.hasException) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Error: ${result.exception.toString()}'),
                  const SizedBox(height: 12),
                  ElevatedButton(onPressed: refetch, child: const Text('Retry')),
                ],
              ),
            );
          }

          // 2. Initial loading condition (when the cache data is empty)
          if (result.isLoading && result.data == null) {
            return const Center(child: CircularProgressIndicator());
          }

          // 3. Successful data condition
          final dataMap = result.data!['productList'];
          final List rawItems = dataMap['items'];
          final bool hasNextPage = dataMap['hasNextPage'] as bool;

          // Converting the data map into an entity list
          final productList = rawItems.map((json) => Product.fromJson(json)).toList();

          return Column(
            children: [
              if (result.isLoading)
                const LinearProgressIndicator(), // Background refresh indicator
              Expanded(
                child: ListView.builder(
                  itemCount: productList.length + (hasNextPage ? 1 : 0),
                  itemBuilder: (context, index) {
                    if (index == productList.length) {
                      // Load More button for async pagination
                      return Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: OutlinedButton(
                          onPressed: () {
                            fetchMore!(
                              FetchMoreOptions(
                                variables: {'page': 2}, // Try fetching the second page
                                updateQuery: (Map<String, dynamic>? oldData, Map<String, dynamic>? newData) {
                                  // Combine the old item list with the new items
                                  final List oldItems = oldData!['productList']['items'];
                                  final List newItems = newData!['productList']['items'];
                                  
                                  newData['productList']['items'] = [...oldItems, ...newItems];
                                  return newData;
                                },
                              ),
                            );
                          },
                          child: const Text('Load More'),
                        ),
                      );
                    }
                    
                    final product = productList[index];
                    return ListTile(
                      leading: Image.network(product.thumbnail, width: 50, errorBuilder: (_, __, ___) => const Icon(Icons.image)),
                      title: Text(product.name),
                      subtitle: Text('Rp ${product.price.toStringAsFixed(0)}'),
                    );
                  },
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

Modifying Data with the Mutation Widget #

To send data or change state on the server (like adding a shopping item), you use the Mutation widget. This library also allows you to instantly update the local cache via the update property so the UI syncs immediately without needing a connection reload.

// presentation/widgets/add_to_cart_button.dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

const String _addItemMutation = r'''
  mutation AddCartItem($id: ID!, $qty: Int!) {
    addItem(productId: $id, qty: $qty) {
      successStatus
      responseMessage
      cart {
        totalItems
        totalPrice
      }
    }
  }
''';

// We also need the cart query to update its cache later
const String _fetchCartQuery = r'''
  query FetchShoppingCart {
    cart {
      totalItems
      totalPrice
    }
  }
''';

class GraphQLAddToCartButton extends StatelessWidget {
  final String productId;

  const GraphQLAddToCartButton({super.key, required this.productId});

  @override
  Widget build(BuildContext context) {
    return Mutation(
      options: MutationOptions(
        document: gql(_addItemMutation),
        // Manually changing the local cache so the cart UI updates instantly
        update: (GraphQLDataProxy cacheProxy, QueryResult? mutationResult) {
          if (mutationResult?.data != null) {
            // Rewrite the cart query cache with the latest data from the mutation result
            cacheProxy.writeQuery(
              Request(operation: Operation(document: gql(_fetchCartQuery))),
              data: {
                'cart': mutationResult!.data!['addItem']['cart'],
              },
            );
          }
        },
        onCompleted: (Map<String, dynamic>? dataMap) {
          if (dataMap != null && dataMap['addItem']['successStatus']) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Successfully added to our shopping cart!')),
            );
          }
        },
        onError: (OperationException? error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Error: ${error?.graphqlErrors.first.message}')),
          );
        },
      ),
      builder: (RunMutation triggerMutation, QueryResult? result) {
        final isLoading = result?.isLoading ?? false;

        return ElevatedButton.icon(
          onPressed: isLoading
              ? null
              : () {
                  // Trigger the mutation submission including the payload variables
                  triggerMutation({'id': productId, 'qty': 1});
                },
          icon: isLoading
              ? const SizedBox(
                  width: 18,
                  height: 18,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : const Icon(Icons.shopping_bag_outlined),
          label: const Text('Buy Now'),
        );
      },
    );
  }
}

Real-Time Synchronization with Subscriptions #

The real-time feature in GraphQL runs on the WebSocket protocol. You use the Subscription widget to listen to constant data streams actively emitted by the server without needing to trigger repeated polling requests.

// presentation/widgets/courier_tracker_widget.dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

const String _trackCourierSub = r'''
  subscription TrackStoreCourier($orderId: ID!) {
    courierStatusUpdate(orderId: $orderId) {
      deliveryStatus
      latitude
      longitude
    }
  }
''';

class CourierTrackerWidget extends StatelessWidget {
  final String orderId;

  const CourierTrackerWidget({super.key, required this.orderId});

  @override
  Widget build(BuildContext context) {
    return Subscription(
      options: SubscriptionOptions(
        document: gql(_trackCourierSub),
        variables: {'orderId': orderId},
      ),
      builder: (QueryResult result) {
        if (result.isLoading) {
          return const Center(child: Text('Connecting to the courier GPS satellite...'));
        }
        
        if (result.hasException) {
          return Center(child: Text('Failed to track: ${result.exception.toString()}'));
        }
        
        if (result.data == null) {
          return const Center(child: Text('Waiting for the latest courier location data...'));
        }

        final statusData = result.data!['courierStatusUpdate'];
        final String status = statusData['deliveryStatus'];
        final double lat = statusData['latitude'];
        final double lng = statusData['longitude'];

        return Card(
          elevation: 4,
          child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Text('Courier Status: $status', style: const TextStyle(fontWeight: FontWeight.bold)),
                const SizedBox(height: 8),
                Text('Current Coordinates: $lat, $lng'),
              ],
            ),
          ),
        );
      },
    );
  }
}

Choosing the Right Fetch Policy #

The GraphQL client has a very sophisticated local cache manipulation mechanism through Fetch Policy settings. You can determine how the client should interact with the local cache and the internet network server:

  1. FetchPolicy.cacheFirst (Default): The client reads the local cache first. If cache data exists, immediately display it to the UI and never hit the internet. Use for rarely changing data (like province lists).
  2. FetchPolicy.cacheAndNetwork: The client immediately renders data from the local cache if available (so the screen loads very fast), then simultaneously makes an internet network request in the background. When the network response returns, the client updates the local cache database and smoothly updates the UI. Highly recommended for social media post lists or home pages.
  3. FetchPolicy.networkOnly: The client ignores the local cache and directly forces an internet network request. Use this for payment mutations, latest shopping carts, or sensitive transaction data.
  4. FetchPolicy.cacheOnly: The client only reads data from the local cache without ever sending requests to the internet network. Useful when the app runs in offline mode.
  5. FetchPolicy.noCache: The client directly fetches data from the internet network without ever storing the results in your local cache database.

GraphQL Fragments: Field Declaration Efficiency #

Often you have to define the same property columns in various different query types (like product id, name, and price). Writing those columns repeatedly violates the DRY (Don’t Repeat Yourself) principle.

You can create Fragments to define a group of re-usable fields:

// 1. Define our re-usable fragment
const String _productPropertiesFragment = '''
  fragment ProductDetailFields on Product {
    id
    name
    price
    thumbnail
  }
''';

// 2. Use the fragment inside the product detail Query
const String _productDetailQuery = '''
  $_productPropertiesFragment
  
  query GetDetail($id: ID!) {
    productDetail(id: $id) {
      ...ProductDetailFields
      fullDescription
      stockCount
    }
  }
''';

// 3. Use the same fragment inside the product search Query
const String _searchProductsQuery = '''
  $_productPropertiesFragment
  
  query Search($q: String!) {
    productSearch(query: $q) {
      ...ProductDetailFields
    }
  }
''';

Using GraphQL Imperatively with Riverpod #

Using builder widgets like Query or Mutation inside Flutter UI code sometimes makes your visual file structures very long and hard to read. You can separate all query and mutation logic from the UI by calling the GraphQLClient instance imperatively inside the State Management Notifier layer (Riverpod).

Here’s a product controller implementation using GraphQL imperatively:

// presentation/providers/graphql_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import '../../domain/entities/product.dart';
import '../errors/exceptions.dart';

// Provider to expose the GraphQLClient instance
final graphqlClientProvider = Provider<GraphQLClient>((ref) {
  return GraphQLClientConfig.initializeClient();
});

class ProductGraphQLNotifier extends AutoDisposeAsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    return _fetchDataFromNetwork();
  }

  Future<List<Product>> _fetchDataFromNetwork() async {
    // 1. Read the client instance from the DI Provider
    final client = ref.read(graphqlClientProvider);

    // 2. Perform the query imperatively without using builder widgets
    final QueryResult result = await client.query(
      QueryOptions(
        document: gql(_fetchProductsQuery),
        variables: {'page': 1},
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
    );

    // 3. Handle exceptions structurally
    if (result.hasException) {
      final error = result.exception!;
      throw AppException(
        error.graphqlErrors.isNotEmpty 
            ? error.graphqlErrors.first.message 
            : 'Failed to load products from the GraphQL server.',
      );
    }

    // 4. Return the pure parsed data
    final List rawItems = result.data!['productList']['items'];
    return rawItems.map((json) => Product.fromJson(json)).toList();
  }

  // Performing mutations imperatively
  Future<void> addShoppingItem(String productId) async {
    final client = ref.read(graphqlClientProvider);

    final QueryResult result = await client.mutate(
      MutationOptions(
        document: gql(_addItemMutation),
        variables: {'id': productId, 'qty': 1},
      ),
    );

    if (result.hasException) {
      throw AppException(result.exception!.graphqlErrors.first.message);
    }
    
    // Refresh our Notifier data status after a successful mutation
    ref.invalidateSelf();
  }
}

final graphQLProductListProvider = AsyncNotifierProvider.autoDispose<ProductGraphQLNotifier, List<Product>>(
  ProductGraphQLNotifier.new,
);

With this functional architecture, your UI widgets just monitor data using ref.watch(graphQLProductListProvider) purely without needing to touch the gql GraphQL syntax or the library’s built-in builder widgets.

Summary #

  • GraphQL gives full control to the client to request data properties dynamically to minimize data over-fetching and under-fetching.
  • GraphQL’s Three Main Operations consist of Query (reading data), Mutation (changing data), and Subscription (real-time data streams based on WebSockets).
  • Multi-Link Chains are implemented by combining HTTP links (HttpLink) and WebSockets (WebSocketLink) using the Link.split route divider function.
  • AuthLink automatically injects the Bearer security token into request headers for all your GraphQL communications.
  • Persistent Caching leverages the local HiveStore to serve data instantly when offline via the FetchPolicy.cacheAndNetwork configuration.
  • GraphQL Fragments simplify re-usable field declarations to prevent duplication of data column writing across various queries.
  • Imperative GraphQL can be cleanly integrated with Riverpod Notifiers to separate all data queries from your UI widget screens.

← Previous: Authentication   Next: Best Practice →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact