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
HttpLinkuntuk Query/Mutation danWebSocketLinkuntuk Subscription, digabungkan denganLink.split.- Gunakan
AuthLinkuntuk inject token ke setiap request — tidak perlu menambahkan header secara manual di setiap query.HiveStoreuntuk persistent cache — data tetap tersedia saat offline, danFetchPolicy.cacheAndNetworkmenampilkan 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, danSubscriptionmenyederhanakan 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.