GraphQL #

GraphQL adalah bahasa query untuk API yang memberikan klien kontrol penuh atas data yang diminta. Berbeda dari REST di mana server menentukan struktur response, GraphQL memungkinkan klien menentukan persis field apa yang dibutuhkan — tidak lebih, tidak kurang. Ini mengeliminasi dua masalah umum REST: over-fetching (data berlebih yang tidak digunakan) dan under-fetching (harus beberapa request untuk satu tampilan).

Konsep Dasar GraphQL #

Query, Mutation, Subscription #

# QUERY -- membaca data (setara GET di REST)
query GetProduk($id: ID!) {
  produk(id: $id) {
    id
    nama
    harga
    kategori {
      id
      nama
    }
    # Hanya minta field yang dibutuhkan -- tidak ada over-fetching
  }
}

# MUTATION -- mengubah data (setara POST/PUT/DELETE di REST)
mutation TambahKeranjang($produkId: ID!, $jumlah: Int!) {
  tambahKeKeranjang(produkId: $produkId, jumlah: $jumlah) {
    success
    keranjang {
      id
      totalItem
      totalHarga
    }
  }
}

# SUBSCRIPTION -- data real-time (WebSocket)
subscription OnStatusPesananBerubah($pesananId: ID!) {
  statusPesananBerubah(pesananId: $pesananId) {
    id
    status
    updatedAt
  }
}

Perbedaan REST vs GraphQL #

REST:
  GET /produk/:id          → full produk object (meski hanya butuh nama dan harga)
  GET /produk/:id/ulasan   → request kedua untuk ulasan
  GET /produk/:id/penjual  → request ketiga untuk info penjual
  
  = 3 request, banyak data tidak terpakai

GraphQL (satu request):
  query {
    produk(id: "123") {
      nama          # hanya field yang dibutuhkan
      harga
      ulasan(limit: 5) {
        rating
        komentar
      }
      penjual {
        nama
        rating
      }
    }
  }
  = 1 request, persis data yang dibutuhkan

Instalasi #

# pubspec.yaml
dependencies:
  graphql_flutter: ^5.2.0-beta.7

Setup GraphQL Client #

// core/network/graphql_client.dart
import 'package:graphql_flutter/graphql_flutter.dart';

class GraphQLClientConfig {
  static GraphQLClient create({String? token}) {
    final authLink = AuthLink(
      getToken: () async {
        // Ambil token dari storage
        final token = await TokenStorage.getAccessToken();
        return token != null ? 'Bearer $token' : null;
      },
    );

    final httpLink = HttpLink('https://api.contoh.com/graphql');

    // WebSocket untuk subscription
    final wsLink = WebSocketLink(
      'wss://api.contoh.com/graphql',
      config: SocketClientConfig(
        autoReconnect: true,
        inactivityTimeout: const Duration(seconds: 30),
        initialPayload: () async {
          final token = await TokenStorage.getAccessToken();
          return {'Authorization': 'Bearer $token'};
        },
      ),
    );

    // Gabungkan: HTTP untuk Query/Mutation, WebSocket untuk Subscription
    final link = authLink.concat(
      Link.split(
        (request) => request.isSubscription,
        wsLink,
        httpLink,
      ),
    );

    return GraphQLClient(
      link: link,
      cache: GraphQLCache(store: HiveStore()),  // persistent cache
      defaultPolicies: DefaultPolicies(
        query: Policies(
          fetch: FetchPolicy.cacheFirst,     // cache dulu, background refresh
        ),
        mutate: Policies(
          fetch: FetchPolicy.networkOnly,    // mutation selalu ke server
        ),
      ),
    );
  }
}

Daftarkan GraphQLProvider #

// main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await initHiveForFlutter();  // init Hive untuk cache GraphQL

  runApp(
    GraphQLProvider(
      client: ValueNotifier(GraphQLClientConfig.create()),
      child: const MyApp(),
    ),
  );
}

Query Widget — Baca Data #

// Definisikan query sebagai konstanta
const _getProdukQuery = '''
  query GetProdukList(\$kategori: String, \$page: Int) {
    produkList(kategori: \$kategori, page: \$page) {
      items {
        id
        nama
        harga
        thumbnail
        rating
      }
      totalCount
      hasNextPage
    }
  }
''';

class ProdukListScreen extends StatelessWidget {
  final String? kategori;
  const ProdukListScreen({super.key, this.kategori});

  @override
  Widget build(BuildContext context) {
    return Query(
      options: QueryOptions(
        document: gql(_getProdukQuery),
        variables: {
          'kategori': kategori,
          'page': 1,
        },
        fetchPolicy: FetchPolicy.cacheAndNetwork,
        // pollInterval: const Duration(minutes: 1), // polling opsional
      ),
      builder: (QueryResult result, {refetch, fetchMore}) {
        // Loading state
        if (result.isLoading && result.data == null) {
          return const Center(child: CircularProgressIndicator());
        }

        // Error state
        if (result.hasException) {
          return ErrorView(
            pesan: _parseGraphQLError(result.exception!),
            onRetry: refetch,
          );
        }

        // Data state
        final data = result.data!['produkList'];
        final items = (data['items'] as List)
            .map((json) => Produk.fromJson(json))
            .toList();
        final hasNextPage = data['hasNextPage'] as bool;

        return Column(
          children: [
            // Tampilkan data lama saat refresh di background
            if (result.isLoading)
              const LinearProgressIndicator(),
            Expanded(
              child: ListView.builder(
                itemCount: items.length + (hasNextPage ? 1 : 0),
                itemBuilder: (context, index) {
                  if (index == items.length) {
                    // Load more button
                    return TextButton(
                      onPressed: () => fetchMore!(FetchMoreOptions(
                        variables: {'page': 2},
                        updateQuery: (prev, next) {
                          final prevItems = prev!['produkList']['items'] as List;
                          final nextItems = next!['produkList']['items'] as List;
                          next['produkList']['items'] = [...prevItems, ...nextItems];
                          return next;
                        },
                      )),
                      child: const Text('Muat Lebih'),
                    );
                  }
                  return ProdukTile(produk: items[index]);
                },
              ),
            ),
          ],
        );
      },
    );
  }

  String _parseGraphQLError(OperationException exception) {
    if (exception.graphqlErrors.isNotEmpty) {
      return exception.graphqlErrors.first.message;
    }
    if (exception.linkException != null) {
      return 'Tidak ada koneksi internet';
    }
    return 'Terjadi kesalahan';
  }
}

Mutation Widget — Ubah Data #

const _tambahKeranjangMutation = '''
  mutation TambahKeranjang(\$produkId: ID!, \$jumlah: Int!) {
    tambahKeKeranjang(produkId: \$produkId, jumlah: \$jumlah) {
      success
      message
      keranjang {
        id
        totalItem
        totalHarga
      }
    }
  }
''';

class TambahKeranjangButton extends StatelessWidget {
  final String produkId;
  const TambahKeranjangButton({super.key, required this.produkId});

  @override
  Widget build(BuildContext context) {
    return Mutation(
      options: MutationOptions(
        document: gql(_tambahKeranjangMutation),
        // Update cache setelah mutation
        update: (cache, result) {
          if (result?.data != null) {
            // Invalidate query keranjang agar direfresh
            cache.writeQuery(
              Request(
                operation: Operation(document: gql(_getKeranjangQuery)),
              ),
              data: result!.data!['tambahKeKeranjang'],
            );
          }
        },
        onCompleted: (data) {
          if (data != null && data['tambahKeKeranjang']['success']) {
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text('Berhasil ditambahkan ke keranjang!')),
            );
          }
        },
        onError: (error) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(_parseError(error!))),
          );
        },
      ),
      builder: (RunMutation runMutation, QueryResult? result) {
        return ElevatedButton.icon(
          onPressed: result?.isLoading ?? false
              ? null
              : () => runMutation({'produkId': produkId, 'jumlah': 1}),
          icon: result?.isLoading ?? false
              ? const SizedBox(
                  width: 16, height: 16,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              : const Icon(Icons.add_shopping_cart),
          label: const Text('Tambah ke Keranjang'),
        );
      },
    );
  }

  String _parseError(OperationException e) {
    return e.graphqlErrors.isNotEmpty
        ? e.graphqlErrors.first.message
        : 'Gagal menambahkan ke keranjang';
  }
}

Subscription — Data Real-time #

const _statusPesananSubscription = '''
  subscription OnStatusPesanan(\$pesananId: ID!) {
    statusPesananBerubah(pesananId: \$pesananId) {
      id
      status
      keterangan
      updatedAt
    }
  }
''';

class StatusPesananWidget extends StatelessWidget {
  final String pesananId;
  const StatusPesananWidget({super.key, required this.pesananId});

  @override
  Widget build(BuildContext context) {
    return Subscription(
      options: SubscriptionOptions(
        document: gql(_statusPesananSubscription),
        variables: {'pesananId': pesananId},
      ),
      builder: (QueryResult result) {
        if (result.isLoading) {
          return const Text('Menghubungkan...');
        }
        if (result.hasException) {
          return Text('Error: ${result.exception}');
        }
        if (result.data == null) {
          return const Text('Menunggu update...');
        }

        final data = result.data!['statusPesananBerubah'];
        final status = data['status'] as String;
        final keterangan = data['keterangan'] as String?;

        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            StatusChip(status: status),
            if (keterangan != null) Text(keterangan),
          ],
        );
      },
    );
  }
}

Cache Policy #

// FetchPolicy options -- sesuaikan dengan kebutuhan
FetchPolicy.cacheFirst       // tampilkan cache, refresh di background (default)
FetchPolicy.cacheOnly        // hanya dari cache, tidak request ke server
FetchPolicy.cacheAndNetwork  // tampilkan cache, lalu update dari network
FetchPolicy.networkOnly      // selalu dari network, update cache
FetchPolicy.noCache          // dari network, tidak simpan ke cache

// Contoh penggunaan
QueryOptions(
  document: gql(query),
  fetchPolicy: FetchPolicy.cacheAndNetwork,  // tampilkan data lama sambil refresh
  errorPolicy: ErrorPolicy.all,              // tampilkan partial data meski ada error
)

Fragments — Reuse Field Selection #

Fragment menghindari duplikasi field yang sama di banyak query:

const _produkFragment = '''
  fragment ProdukFields on Produk {
    id
    nama
    harga
    thumbnail
    rating
    tersedia
  }
''';

// Gunakan fragment di query
const _getProdukQuery = '''
  \$_produkFragment
  
  query GetProduk(\$id: ID!) {
    produk(id: \$id) {
      ...ProdukFields
      deskripsi
      ulasan {
        id
        rating
        komentar
      }
    }
  }
''';

// Fragment yang sama bisa digunakan di query lain
const _searchProdukQuery = '''
  \$_produkFragment
  
  query SearchProduk(\$q: String!) {
    searchProduk(query: \$q) {
      ...ProdukFields
    }
  }
''';

Integrasi dengan Riverpod #

Untuk menggunakan GraphQL tanpa Widget builder, gunakan GraphQL client secara imperatif:

// Provider untuk GraphQL client
final graphqlClientProvider = Provider<GraphQLClient>((ref) {
  return GraphQLClientConfig.create();
});

// Notifier yang menggunakan GraphQL client langsung
class ProdukGraphQLNotifier extends AsyncNotifier<List<Produk>> {
  @override
  Future<List<Produk>> build() => _fetchProduk();

  Future<List<Produk>> _fetchProduk() async {
    final client = ref.read(graphqlClientProvider);
    final result = await client.query(
      QueryOptions(
        document: gql(_getProdukQuery),
        fetchPolicy: FetchPolicy.cacheAndNetwork,
      ),
    );

    if (result.hasException) {
      throw AppException(
        result.exception!.graphqlErrors.isNotEmpty
            ? result.exception!.graphqlErrors.first.message
            : 'Gagal memuat produk',
      );
    }

    final items = result.data!['produkList']['items'] as List;
    return items.map((json) => Produk.fromJson(json)).toList();
  }

  Future<void> tambahKeranjang(String produkId) async {
    final client = ref.read(graphqlClientProvider);
    final result = await client.mutate(
      MutationOptions(
        document: gql(_tambahKeranjangMutation),
        variables: {'produkId': produkId, 'jumlah': 1},
      ),
    );

    if (result.hasException) {
      throw AppException(result.exception!.graphqlErrors.first.message);
    }
  }
}

final produkGraphQLProvider =
    AsyncNotifierProvider<ProdukGraphQLNotifier, List<Produk>>(
  ProdukGraphQLNotifier.new,
);

Ringkasan #

  • GraphQL memungkinkan klien meminta persis field yang dibutuhkan — mengeliminasi over-fetching dan under-fetching yang umum di REST.
  • Tiga operasi GraphQL: Query (baca data), Mutation (ubah data), Subscription (data real-time via WebSocket).
  • Setup GraphQL client dengan HttpLink untuk Query/Mutation dan WebSocketLink untuk Subscription, digabungkan dengan Link.split.
  • Gunakan AuthLink untuk inject token ke setiap request — tidak perlu menambahkan header secara manual di setiap query.
  • HiveStore untuk persistent cache — data tetap tersedia saat offline, dan FetchPolicy.cacheAndNetwork menampilkan data lama sambil refresh di background.
  • Fragment menghindari duplikasi field selection yang sama di banyak query — definisikan sekali, gunakan di mana saja.
  • Widget Query, Mutation, dan Subscription menyederhanakan integrasi di UI. Untuk kontrol lebih penuh, panggil client secara imperatif melalui Riverpod Notifier.
  • Pilih GraphQL jika: banyak tipe klien (web, mobile, tablet) dengan kebutuhan data berbeda, data kompleks dengan banyak relasi, atau butuh real-time subscription yang built-in.

← Sebelumnya: Authentication   Berikutnya: Best Practice →

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