Beberapa waktu lalu saya ngerjain fitur feed yang mirip-mirip Instagram di salah satu project React Native. Kelihatannya simpel ya, tinggal map() data terus render, kan? Ternyata pas datanya udah ratusan item dan user scroll cepet-cepet, HP mulai berat, scroll jadi patah-patah, bahkan sempet nge-freeze beberapa detik. Nah di sini saya mau sharing apa aja yang saya pelajari waktu benerin masalah ini.
Kenapa bisa lag?
Awalnya saya mikir “ah paling tinggal render aja, React kan udah pinter.” Ternyata nggak sesederhana itu. Kalau semua item di-render sekaligus, termasuk yang nggak kelihatan di layar, itu sama aja kayak nyuruh HP kerja buat nampilin 500 foto sekaligus padahal yang keliatan cuma 5. Buang-buang resource banget.
Terus tiap kali ada state berubah dikit (misal loading spinner nongol), kadang seluruh list ikut re-render. Nah kombinasi dua hal ini yang bikin app jadi berat.
Solusinya ada di satu kata: virtualisasi. Intinya cuma render yang keliatan di layar aja, sisanya “dibuang” dulu sampai user scroll ke situ.
Ganti FlatList ke FlashList, langsung kerasa bedanya
Awalnya saya pakai FlatList bawaan React Native. Lumayan sih, tapi begitu list-nya panjang dan ada campuran foto + video, mulai kerasa jank-nya, apalagi kalau tinggi tiap item beda-beda (kayak feed foto pada umumnya).
Terus saya coba FlashList dari Shopify, dan lumayan kaget sama bedanya. Cara kerjanya beda, dia recycle komponen yang udah ada, bukan bikin baru terus buang terus, mirip kayak RecyclerView di Android native. Jadi beban buat garbage collector jauh lebih ringan.
npm install @shopify/flash-list
import { FlashList } from "@shopify/flash-list";
<FlashList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
estimatedItemSize={400}
onEndReached={loadMorePosts}
onEndReachedThreshold={0.5}
/>
Satu hal yang sempet bikin saya bingung: estimatedItemSize. Ini wajib diisi (perkiraan tinggi item), kalau nggak, FlashList bakal susah ngitung layout awal dan malah jadi aneh renderingnya. Nggak harus presisi banget, kira-kira aja udah cukup.
Kalau masih pakai FlatList, ini yang perlu di-tweak
Saya paham nggak semua project bisa langsung ganti library gitu aja (kadang ada constraint dependency atau tim belum sreg). Kalau masih pakai FlatList, ada beberapa props yang ternyata pengaruh banget:
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={(item) => item.id}
windowSize={5}
maxToRenderPerBatch={5}
initialNumToRender={5}
updateCellsBatchingPeriod={50}
removeClippedSubviews={true}
onEndReached={loadMorePosts}
onEndReachedThreshold={0.5}
getItemLayout={(data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>
Yang paling kerasa dampaknya buat saya itu getItemLayout. Kalau tinggi item konsisten (misalnya semua card sama tingginya), kasih tau FlatList langsung lewat props ini. Dia jadi nggak perlu ngukur-ngukur layout secara dinamis, dan scroll jadi jauh lebih mulus. Sayangnya kalau tinggi item dinamis (kayak caption panjang-pendek), ini agak tricky, dan biasanya di sinilah orang-orang akhirnya pindah ke FlashList.
Soal pagination, jangan pakai offset
Ini pelajaran yang saya dapet dengan cara yang agak nyakitin. Awalnya saya pakai pagination model page / offset biasa. Masalahnya, karena datanya terus berubah (feed real-time), pas ada post baru masuk pas user lagi scroll, data jadi kacau, ada yang keduplikat, ada yang skip.
Pindah ke cursor-based pagination dan masalah itu ilang. Jadi bukan “ambil halaman ke-3”, tapi “ambil data setelah item dengan id/cursor ini”.
const [posts, setPosts] = useState([]);
const [cursor, setCursor] = useState(null);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMorePosts = async () => {
if (loading || !hasMore) return;
setLoading(true);
const res = await fetch(`/api/posts?cursor=${cursor}&limit=10`);
const data = await res.json();
setPosts(prev => [...prev, ...data.items]);
setCursor(data.nextCursor);
setHasMore(!!data.nextCursor);
setLoading(false);
};
Oh iya, penting banget kasih guard if (loading || !hasMore) return. Dulu saya nggak kasih ini dan onEndReached bisa kepanggil berkali-kali dalam sepersekian detik pas scroll cepet, hasilnya data keduplikat semua di list. Kena bug ini sendiri baru sadar pentingnya guard kayak gini haha.
Item-nya sendiri juga perlu dioptimasi
Ini bagian yang sering keluput padahal efeknya lumayan gede.
Bungkus komponen item pakai React.memo. Biar item yang datanya nggak berubah nggak ikut re-render pas parent-nya render ulang (misal gara-gara state loading berubah).
const PostCard = React.memo(({ post }) => {
return (
<View>
<Image source={{ uri: post.imageUrl }} />
<Text>{post.caption}</Text>
</View>
);
}, (prevProps, nextProps) => prevProps.post.id === nextProps.post.id);
Hindari bikin function baru tiap render di dalam renderItem. Ini kesalahan yang saya lakuin berkali-kali sebelum sadar. Function inline itu bikin referensinya selalu “baru”, jadi React.memo di atas jadi percuma karena selalu dianggap beda.
// jangan gini
<FlatList renderItem={({ item }) => <PostCard post={item} onPress={() => handlePress(item)} />} />
// mending gini
const renderItem = useCallback(({ item }) => <PostCard post={item} />, []);
<FlatList renderItem={renderItem} />
Gambar juga jangan disepelekan. Buat feed yang isinya foto, Image bawaan React Native itu nggak punya caching yang bagus. Saya pindah ke expo-image, dan lumayan berasa, terutama waktu user scroll balik ke atas. Gambar yang udah pernah dimuat langsung muncul instan karena kena cache.
Kalau ada video (kayak Reels)
Ini pernah jadi masalah tersendiri. Kalau video di-render dan langsung autoplay semua meskipun lagi nggak keliatan di layar, HP bisa panas dan baterai boros parah, apalagi kalau videonya banyak dalam satu scroll session.
Solusinya, pantau item mana yang lagi keliatan pakai onViewableItemsChanged, terus cuma video itu aja yang diizinin play, sisanya di-pause.
const onViewableItemsChanged = useRef(({ viewableItems }) => {
const visibleId = viewableItems[0]?.item?.id;
setActiveVideoId(visibleId);
}).current;
<FlatList
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={{ itemVisiblePercentThreshold: 80 }}
...
/>
Tiap komponen video tinggal cek post.id === activeVideoId buat nentuin dia harus play atau nggak.
Hal-hal kecil lain yang ternyata lumayan berpengaruh
- Kasih skeleton/placeholder pas lagi loading data baru, biar nggak keliatan “blank” tiba-tiba pas nyampe bawah list.
- Jangan lupa cek apakah Hermes engine udah aktif (biasanya default sih di RN versi baru), ini ngebantu banget buat performa JS secara keseluruhan.
- Sebelum sok-sokan optimasi macem-macem, coba profiling dulu pakai Flipper atau React DevTools. Saya sempet buang waktu optimasi bagian yang ternyata nggak jadi bottleneck utama, gara-gara nebak doang tanpa data.
Penutup
Intinya, nggak ada satu solusi ajaib buat bikin infinite scroll yang smooth. Ini kombinasi dari beberapa hal kecil yang saling numpuk efeknya: pilih library list yang tepat, pagination yang bener, memoization yang rapi, dan lazy load buat media. Satu-satu keliatan sepele, tapi begitu digabung, bedanya kerasa banget, dari yang tadinya patah-patah jadi mulus kayak native app beneran.
Kalau ada yang punya pengalaman lain atau trik tambahan, boleh banget share di kolom komentar!
