Pagination artinya mengambil list sedikit demi sedikit, satu halaman per request, supaya client gak menerima ribuan item sekaligus. Ada dua cara menyebut "lanjut dari mana". Offset: "lewati 2 baris pertama". Cursor: "lanjutkan setelah buku nomor 2". Bayangin membaca katalog tebal. Offset seperti mengingat "aku sudah baca 2 baris", padahal ada yang mencoret baris di atas. Cursor seperti pembatas buku yang diselipkan tepat setelah buku terakhir yang kamu baca.
🧪 Tebakan cepat: Halaman 1 (dua buku pertama: Laskar Pelangi dan Bumi Manusia) sudah dimuat dengan offset. Lalu admin menghapus Laskar Pelangi dari katalog. Halaman 2, dengan offset 2, berisi apa? Jawabannya ada di bagian "Offset: gampang, tapi bisa bergeser".
Offset: gampang, tapi bisa bergeser
Untuk pelajaran ini, field books di schema Toko Buku dapat dua argument: books(status: StockStatus, offset: Int = 0, limit: Int): [Book!]!. Satu halaman berisi 2 buku:
{
books(offset: 0, limit: 2) {
id
title
}
}
Halaman 1 menjawab buku 1 dan 2. Halaman 2 (offset: 2) menjawab buku 3, Cantik Itu Luka. Masalahnya muncul kalau data berubah di antara dua request:
| Kejadian | Isi tabel | Halaman 2 dengan offset: 2, limit: 2 |
|---|---|---|
| Gak ada yang berubah | 1, 2, 3 | Cantik Itu Luka |
| Laskar Pelangi dihapus setelah halaman 1 dimuat | 2, 3 | [], kosong |
Halaman 2 kosong, jadi Cantik Itu Luka gak pernah tampil di layar walaupun masih ada di toko. Itu jawaban tebakannya. Kebalikannya juga terjadi: buku baru yang masuk di depan list menggeser semuanya, dan satu buku tampil dua kali. Selain itu, offset: 100000 tetap memaksa database membaca lalu membuang 100.000 baris.
Cursor: lanjut setelah item terakhir
Dengan cursor, client gak bilang "lewati 2", tapi "setelah buku nomor 2". Setelah Laskar Pelangi dihapus, buku dengan id lebih besar dari 2 tetap Cantik Itu Luka. Gak ada yang terlewat, gak ada yang dobel.
Connection: bentuk standar dari Relay
Spesifikasi GraphQL sendiri gak mengatur pagination. Bentuk yang dipakai luas, dan yang dijelaskan di halaman pagination graphql.org, datang dari GraphQL Cursor Connections Specification milik Relay. GitHub dan Shopify memakainya. Schema Toko Buku dapat field ini:
type Query {
# ...field yang sudah ada
booksConnection(first: Int = 10, after: String): BookConnection!
}
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type BookEdge {
cursor: String!
node: Book!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
- Connection (
BookConnection) adalah satu halaman:edgesdanpageInfo. - Edge membungkus satu item:
nodeadalah bukunya,cursoradalah posisinya di list. - PageInfo:
hasNextPagebilang masih ada halaman berikutnya,endCursoradalah cursor item terakhir di halaman ini.startCursordanendCursorbernilainullkalau halamannya kosong. firstdanafteruntuk maju: "ambilfirstitem setelah cursorafter". Untuk mundur, spesifikasinya memakailastdanbefore.totalCountbukan bagian spesifikasinya, tapi sering ditambahkan.
Satu halaman berisi 2 buku
query HalamanBuku($first: Int, $after: String) {
booksConnection(first: $first, after: $after) {
edges {
cursor
node {
id
title
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
Variables { "first": 2 }:
{
"data": {
"booksConnection": {
"edges": [
{ "cursor": "Ym9vazox", "node": { "id": "1", "title": "Laskar Pelangi" } },
{ "cursor": "Ym9vazoy", "node": { "id": "2", "title": "Bumi Manusia" } }
],
"pageInfo": { "hasNextPage": true, "endCursor": "Ym9vazoy" },
"totalCount": 3
}
}
}
hasNextPage bernilai true, jadi client mengirim query yang sama dengan { "first": 2, "after": "Ym9vazoy" }, yaitu endCursor tadi. Jawabannya satu edge, Cantik Itu Luka dengan cursor "Ym9vazoz", dan hasNextPage: false. Client berhenti di situ. Teks query-nya sama untuk semua halaman; yang berubah cuma variables.
Cursor opaque: base64 dari apa?
"Ym9vazoy" adalah base64 dari teks "book:2". Di JavaScript: btoa("book:2") menghasilkan "Ym9vazoy", dan atob("Ym9vazoy") mengembalikannya. Opaque artinya client gak boleh membaca atau merakit cursor sendiri. Client cukup mengembalikan endCursor apa adanya. Alasannya: server bebas mengganti isinya, misalnya jadi harga plus id untuk urutan berdasarkan harga, tanpa merusak aplikasi yang sudah ada.
Base64 bukan enkripsi: siapa pun bisa membukanya, jadi jangan simpan rahasia di cursor, dan server harus memeriksanya. Cursor sembarang seperti "abc" dijawab Invalid cursor, dan karena booksConnection bertype BookConnection!, seluruh data jadi null. Hati-hati juga dengan library: connectionFromArray dari graphql-relay membuat cursor dari base64 "arrayconnection:" plus offset, jadi tetap punya masalah bergeser tadi.
Urutan harus stabil
Cursor book:2 artinya "setelah id 2" cuma kalau list-nya diurutkan berdasarkan id, kolom yang unik. Ini resolver-nya di server Toko Buku:
booksConnection: (_, { first, after }) => {
const sorted = [...books].sort((a, b) => a.id - b.id);
const afterId = after == null ? 0 : fromCursor(after); // "Ym9vazoy" → 2
const rest = sorted.filter((b) => b.id > afterId);
const edges = rest.slice(0, first).map((book) => ({ cursor: toCursor(book.id), node: book }));
return {
edges,
pageInfo: {
hasNextPage: rest.length > first,
hasPreviousPage: rest.length < sorted.length,
startCursor: edges.length > 0 ? edges[0].cursor : null,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
totalCount: books.length,
};
},
fromCursormembuka base64 lalu mengambil angka setelahbook:;toCursor(id)kebalikannya.rest.length > firstmengecek apakah masih ada sisa setelah halaman ini.- Di database sungguhan bentuknya
WHERE id > 2 ORDER BY id LIMIT 3: ambil satu lebih darifirst, dan kalau baris ketiga ada,hasNextPagebernilaitrue.
Kalau diurutkan berdasarkan kolom yang bisa kembar, seperti harga, tambahkan id sebagai penentu urutan kedua dan simpan keduanya di cursor. Tanpa itu, dua buku berharga sama bisa terlewat atau tampil dua kali.
⚠️ Jebakan yang sering ketemu
- Offset untuk data yang sering berubah. Feed, chat, atau katalog yang ditambah dan dihapus terus: item terlewat atau dobel. Pakai cursor.
- Client merakit cursor sendiri.
btoa("book:5")di client akan rusak begitu server mengganti isi cursor. Kirim balikendCursorapa adanya. - Urutan berdasarkan kolom yang gak unik. Tambahkan id sebagai penentu urutan kedua.
firsttanpa batas.first: 1000000sama saja dengan tanpa pagination. Batasi di server, misalnya maksimal 100.
TL;DR: Pagination mengambil list per halaman. Offset (offset, limit) gampang dan bisa lompat ke halaman tertentu, tapi bergeser kalau data berubah: setelah Laskar Pelangi dihapus, halaman 2 dengan offset 2 kosong dan Cantik Itu Luka terlewat. Cursor bilang "setelah item ini", jadi tetap benar. Bentuk standarnya Connection dari GraphQL Cursor Connections Specification milik Relay (bukan dari spesifikasi GraphQL): edges berisi node dan cursor, pageInfo berisi hasNextPage dan endCursor, maju dengan first dan after, mundur dengan last dan before. Cursor itu opaque: di Toko Buku isinya base64 dari "book:2", dan client cukup mengembalikan endCursor apa adanya. Urutannya harus stabil, berdasarkan kolom unik.