Relay Connections
offset pagination ระบุ ตำแหน่ง ซึ่งเลื่อนไปเมื่อ list เปลี่ยนแปลง cursor pagination แก้เรื่องนี้ด้วยการระบุ item: แต่ละ item จะได้ cursor ทึบแสงหนึ่งตัว และคุณขอ “item ถัดไป N อัน หลังจาก cursor นี้” เพราะ cursor ตั้งชื่อให้ item เฉพาะตัวแทนที่จะเป็นช่อง การแทรกและลบที่อื่นใน list จึงไม่ทำให้ผลลัพธ์ซ้ำหรือถูกข้ามอีกต่อไป
รูปร่างของ cursor pagination ที่ใช้กันแพร่หลายที่สุดคือ Relay Connection specification ซึ่งกำหนดชื่อ field ให้เป็นมาตรฐาน tooling และ client จึงทำ pagination กับ list ไหนก็ได้ด้วยวิธีเดียวกัน
รูปร่างของ connection
หัวข้อที่มีชื่อว่า “รูปร่างของ connection”connection ห่อ list ไว้ในสามชั้นที่ซ้อนกัน แทนที่จะคืน [Post!]! โดยตรง field ที่เป็น list จะคืน PostConnection:
type Query { posts(first: Int, after: String): PostConnection!}
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo!}
type PostEdge { node: Post! cursor: String!}
type PageInfo { hasNextPage: Boolean! endCursor: String}ถอดรหัสแต่ละชั้น:
edgesคือ list — แต่แต่ละรายการคือ edge ไม่ใช่ node เปล่า ๆnodeคือ item ตัวจริง (Post) การห่อไว้ใน edge เผื่อที่สำหรับข้อมูลเฉพาะของแต่ละ edgecursorคือ string ทึบแสงที่ระบุตำแหน่งของ edge นั้นใน connection นี้ ให้ปฏิบัติเหมือนกล่องดำ อย่า parse เด็ดขาดpageInfoบรรจุ metadata สำหรับ paginghasNextPageบอกว่ามี item เพิ่มอีกหรือไม่ ส่วนendCursorคือ cursor ของ edge สุดท้าย ซึ่งคุณป้อนเข้าไปใน request ถัดไป
first และ after
หัวข้อที่มีชื่อว่า “first และ after”pagination ไปข้างหน้าใช้ argument สองตัว first: N ขอ item ถัดไป N อัน after: cursor บอกว่า “เริ่ม หลังจาก cursor นี้” หน้าแรกละ after ไว้:
{ posts(first: 2) { edges { node { title } cursor } pageInfo { hasNextPage endCursor } }}ในการเอาหน้าถัดไป คุณหยิบ pageInfo.endCursor จาก response แล้วส่งกลับเป็น after (สเปกยังนิยาม last/before สำหรับ paging ย้อนกลับ ที่เป็นภาพสะท้อนกระจกของ first/after)
flowchart TD Conn["PostConnection"] Conn --> Edges["edges: [PostEdge!]!"] Conn --> PI["pageInfo: PageInfo!"] Edges --> Edge["PostEdge"] Edge --> Node["node: Post!"] Edge --> Cursor["cursor: String! (opaque)"] PI --> HNP["hasNextPage: Boolean!"] PI --> EC["endCursor: String — feed into next 'after'"]
connection รันจริง
หัวข้อที่มีชื่อว่า “connection รันจริง”runner ด้านล่างสร้าง schema PostConnection ตัวจริง แล้ว resolve first/after ด้วยการเข้ารหัส id ของแต่ละ item เป็น cursor รันดูเพื่อดึงหน้าหนึ่ง จากนั้นคัดลอก endCursor จากผลลัพธ์ไปใส่ argument after: แล้วรันใหม่เพื่อเดินหน้าต่อ
ผลลัพธ์ให้ edge สองอัน แต่ละอันมี node และ cursor ทึบแสง พร้อมกับ pageInfo ที่บอก hasNextPage: true และส่ง endCursor กลับมา ส่ง endCursor นั้นเป็น after แล้วคุณจะได้สองอันถัดไป — และเพราะ cursor ตั้งชื่อให้ item ไม่ใช่ตำแหน่ง การแทรก post ใหม่ที่ด้านบนของ list จะไม่ทำให้หน้าสองซ้ำหรือข้ามอะไรไป ความเสถียรนั้นคือเหตุผลทั้งหมดที่ connection มีอยู่
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
| stable pagination — item ไม่หาย/ซ้ำเมื่อ data เปลี่ยน | verbose กว่า offset — edges.node แทน item ตรง ๆ |
| รองรับ infinite scroll ได้ดี | ต้องออกแบบ cursor strategy อย่างดี |
pageInfo ให้ข้อมูล hasNextPage, hasPreviousPage ครบ | cursor ที่ opaque ทำให้ skip ไปหน้าใดไม่ได้ |
| มาตรฐาน spec ที่ client library รองรับ (Apollo, Relay) | implementation ซับซ้อนกว่า offset |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Cursor ที่เป็น Offset ซ่อนอยู่ อาการ:
- cursor เป็นแค่
base64(offset)— ไม่ stable - เมื่อ item ถูกลบ cursor เดิมชี้ไปผิด position
- ใช้ opaque cursor จาก unique identifier เช่น
base64(id)หรือbase64(createdAt:id)
Edge Type ที่ไม่มี Custom Field อาการ:
Edgeมีแค่nodeและcursor— ไม่ใช้ประโยชน์ของ Edge- edge สามารถมี relationship metadata เช่น
joinedAtสำหรับ user-in-group
💡 ตัวอย่างจากของจริง
GitHub:
- ทุก list ใช้ Relay Connection spec:
repository.issues(first: 10, after: $cursor)- ทำให้ paginate ผ่าน issue, PR, comment ได้อย่าง consistent
Shopify:
- Storefront API ใช้ Connection ทุกที่ — products, orders, collections
- client library เช่น Hydrogen ใช้ Connection spec สำหรับ automatic pagination