Saat mengelola dataset lokal berukuran 20.000 hingga 100.000 baris di SQLite pada aplikasi React Native, performa scrolling pada FlatList atau FlashList kerap anjlok. Masalah ini umumnya bukan berasal dari layer rendering UI, melainkan dari query pagination yang menggunakan klausa LIMIT OFFSET. Pendekatan ini memicu degradasi waktu eksekusi seiring makin dalamnya posisi scroll pengguna.
Akar Masalah: Kompleksitas Linear LIMIT OFFSET
Klausa OFFSET n pada SQLite bekerja secara linear ($O(n)$). Ketika Anda menjalankan query:
SELECT id, title, created_at
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;SQLite tidak melompat langsung ke baris ke-20.001. Mesin database harus menelusuri B-Tree, memindai 20.000 baris pertama, memvalidasi urutan, membuang seluruh 20.000 baris tersebut ke memori sementara, lalu mengambil 20 baris berikutnya.
Ketika durasi eksekusi query melonjak dari 1ms (pada halaman awal) menjadi 250ms+ (pada halaman akhir), bridge atau JSI thread tertahan. Efek langsung pada React Native adalah frame drop pada JavaScript event loop yang menunda pengiriman data batch ke UI thread, mengakibatkan shuttering atau lag putih saat onEndReached terpicu.
Solusi: Keyset Pagination (Seek Method)
Keyset pagination (seek method) mengeliminasi pemindaian baris-baris sebelumnya dengan memanfaatkan baris terakhir dari halaman berjalan sebagai titik awal penelusuran berikutnya. Kompleksitas query terpangkas menjadi $O(\log n)$ melalui B-Tree search langsung.
Nilai timestamp created_at saja tidak cukup unik karena beberapa baris data dapat memiliki nilai milidetik yang identik. Diperlukan kolom penanda komposit dengan primary key unik, yaitu perbandingan baris ganda (row-value comparison):
SELECT id, title, created_at
FROM items
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;Struktur Indeks Komposit
Keyset pagination berbasis komposit membutuhkan indeks yang secara presisi mencerminkan kondisi filter dan sorting. Buat indeks berikut pada skema database:
CREATE INDEX idx_items_pagination ON items (created_at DESC, id DESC);Perbandingan EXPLAIN QUERY PLAN
Efisiensi metode ini terlihat jelas saat membandingkan output analisis eksekusi SQLite.
1. Pendekatan OFFSET
EXPLAIN QUERY PLAN
SELECT id, title, created_at
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;Output SQLite:
SCAN items USING INDEX idx_items_paginationSQLite menjalankan pemindaian penuh indeks (SCAN) sebanyak 20.020 entri indeks hanya untuk membuang 20.000 di antaranya.
2. Pendekatan Keyset (Cursor)
EXPLAIN QUERY PLAN
SELECT id, title, created_at
FROM items
WHERE (created_at, id) < ('2023-10-01 12:00:00', 5231)
ORDER BY created_at DESC, id DESC
LIMIT 20;Output SQLite:
SEARCH items USING INDEX idx_items_pagination (created_at<? AND (created_at=? AND id<?))Status berubah dari SCAN menjadi SEARCH. Mesin database melakukan pencarian biner langsung ke lokasi pointer B-Tree dalam hitungan pecahan milidetik, tanpa memindai data historis.
Implementasi Custom Hook di React Native
Kode di bawah menunjukkan implementasi hook React Native untuk membaca database secara streaming per halaman cursor tanpa memory leak atau race condition.
import { useState, useRef, useCallback, useEffect } from 'react';
interface Item {
id: number;
title: string;
created_at: string;
}
interface Cursor {
createdAt: string;
id: number;
}
export function useKeysetPagination(db: any, pageSize = 20) {
const [data, setData] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasNextPage, setHasNextPage] = useState(true);
const cursorRef = useRef<Cursor | null>(null);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const fetchNextPage = useCallback(async () => {
if (isLoading || !hasNextPage) return;
setIsLoading(true);
try {
let query = `
SELECT id, title, created_at
FROM items
`;
const params: (string | number)[] = [];
if (cursorRef.current) {
query += ` WHERE (created_at, id) < (?, ?) `;
params.push(cursorRef.current.createdAt, cursorRef.current.id);
}
query += ` ORDER BY created_at DESC, id DESC LIMIT ?;`;
params.push(pageSize);
// Sesuaikan metode eksekusi dengan library Anda (misal: op-sqlite atau expo-sqlite)
const rows: Item[] = await db.executeAsync(query, params);
if (!isMountedRef.current) return;
if (rows.length < pageSize) {
setHasNextPage(false);
}
if (rows.length > 0) {
const lastItem = rows[rows.length - 1];
cursorRef.current = {
createdAt: lastItem.created_at,
id: lastItem.id,
};
setData(prev => [...prev, ...rows]);
}
} catch (error) {
if (isMountedRef.current) {
console.error('Keyset fetch failed:', error);
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [db, pageSize, isLoading, hasNextPage]);
return { data, fetchNextPage, isLoading, hasNextPage };
}Batasan Teknis dan Solusi Alternatif
- Tidak Bisa Loncat ke Halaman Spesifik: Keyset cursor tidak mendukung akses langsung ke halaman arbitrer (seperti navigasi ke Halaman 12). Pendekatan ini murni dirancang untuk navigasi infinite scrolling.
- Filter Kompleks: Jika kueri menambahkan filter baru (misal
status = 'active'), kolom tersebut harus ditempatkan pada posisi awal indeks komposit:(status, created_at DESC, id DESC)untuk mencegah engine fallback ke table scan. - Reverse Traversal: Untuk navigasi mundur, balikkan operator komparasi menjadi
(created_at, id) > (?, ?)dengan urutanORDER BY created_at ASC, id ASC, lalu balik urutan array hasilnya di memori.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!