Akar Masalah: Cell Recycling dan Unhoisted Local State

Render glitch pada list berukuran besar di React Native umumnya berakar dari mekanisme daur ulang native view (cell recycling). Pustaka virtualisasi list seperti @shopify/flash-list mendaur ulang instance komponen UI native yang sudah keluar dari viewport untuk menampilkan item data baru yang masuk ke layar. Hal serupa terjadi pada FlatList saat proses mounting-unmounting node virtual diabaikan oleh rekonsiliasi karena identitas view dianggap sama.

Masalah timbul ketika list item menyimpan unhoisted local state via useState atau useRef (misalnya status toggle, input teks, atau animasi like). Native cell yang di-recycle tidak otomatis memicu reset local state jika siklus hidup komponen React dipertahankan oleh engine list. Akibatnya, status visual dari data baris ke-1 akan 'bocor' dan tampil pada baris ke-20 saat pengguna melakukan scroll cepat.

Anti-Pattern: Local State pada List Item

Pola di bawah ini memperlihatkan anti-pattern umum: menyimpan status seleksi di dalam komponen anak secara mandiri.

// ANTI-PATTERN: State leak saat cell di-recycle
import React, { useState } from 'react';
import { Text, TouchableOpacity, StyleSheet } from 'react-native';

interface ItemProps {
  title: string;
}

export const BadListItem = ({ title }: ItemProps) => {
  // Local state tidak tersinkronisasi saat item didaur ulang
  const [isSelected, setIsSelected] = useState(false);

  return (
    <TouchableOpacity 
      onPress={() => setIsSelected(prev => !prev)}
      style={[styles.box, isSelected && styles.selected]}
    >
      <Text>{title}</Text>
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  box: { padding: 16, backgroundColor: '#f0f0f0' },
  selected: { backgroundColor: '#a3e635' },
});

Ketika BadListItem di-recycle, nilai isSelected tetap bernilai true meskipun data title yang dioper sudah berganti. React tidak menganggap komponen ini baru karena struktur pohon komponen tidak berubah, hanya menerima prop baru tanpa me-reset state internal.

Solusi: State Hoisting ke External Store atau Parent State

Solusi arsitektural yang benar adalah mengubah cell component menjadi pure controlled component. Pindahkan kepemilikan state ke level parent, atau gunakan state manager eksternal seperti Zustand agar cell hanya membaca nilai berdasarkan ID unik item.

// IMPLEMENTASI BENAR: Pure Controlled Component
import React, { memo } from 'react';
import { Text, TouchableOpacity, StyleSheet } from 'react-native';

interface GoodListItemProps {
  id: string;
  title: string;
  isSelected: boolean;
  onToggle: (id: string) => void;
}

export const GoodListItem = memo(({ id, title, isSelected, onToggle }: GoodListItemProps) => {
  return (
    <TouchableOpacity 
      onPress={() => onToggle(id)}
      style={[styles.box, isSelected && styles.selected]}
    >
      <Text>{title}</Text>
    </TouchableOpacity>
  );
}, (prev, next) => {
  return prev.id === next.id && 
         prev.isSelected === next.isSelected && 
         prev.title === next.title;
});

const styles = StyleSheet.create({
  box: { padding: 16, backgroundColor: '#f0f0f0', marginBottom: 8 },
  selected: { backgroundColor: '#38bdf8' },
});

Implementasi parent memanfaatkan data struktur berbasis Set atau Record<string, boolean> untuk memastikan lookup bernilai O(1) tanpa memicu render ulang total yang tidak perlu:

import React, { useState, useCallback } from 'react';
import { FlashList } from '@shopify/flash-list';
import { GoodListItem } from './GoodListItem';

interface DataItem {
  id: string;
  title: string;
}

export const ListScreen = ({ data }: { data: DataItem[] }) => {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());

  const handleToggle = useCallback((id: string) => {
    setSelectedIds(prev => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }, []);

  const renderItem = useCallback(({ item }: { item: DataItem }) => (
    <GoodListItem 
      id={item.id}
      title={item.title}
      isSelected={selectedIds.has(item.id)}
      onToggle={handleToggle}
    />
  ), [selectedIds, handleToggle]);

  return (
    <FlashList
      data={data}
      renderItem={renderItem}
      keyExtractor={(item) => item.id}
      estimatedItemSize={60}
    />
  );
};

Peran Kritis keyExtractor yang Stabil

Menggunakan array index sebagai key merupakan kesalahan fatal dalam virtualized list:

// ANTI-PATTERN: Menyebabkan rekonsiliasi salah sasaran saat list berubah
keyExtractor={(item, index) => index.toString()}

Ketika item dihapus, disortir ulang, atau difilter, index numerik tetap urut (0, 1, 2, ...). React reconciler menyimpulkan bahwa komponen pada posisi index tersebut tidak berubah secara identitas, sehingga native view mempertahankan state elemen sebelumnya. Selalu gunakan identifier unik dan permanen dari database/backend:

// PRAKTIK TERBAIK: Key berbasis entitas bisnis yang stabil
keyExtractor={(item) => item.id}

Optimasi Menjaga Performa 60/120 FPS

Memindahkan state ke parent berisiko memicu re-render pada seluruh list setiap kali satu item diubah. Terapkan strategi berikut untuk menjaga framerate stabil pada 60 FPS atau 120 FPS di layar ProMotion/High Refresh Rate:

  • Gunakan React.memo dengan Selector Akurat: Jangan passing keseluruhan objek jika cell hanya membutuhkan sedikit field. Membatasi prop pada tipe primitif (string, boolean, number) mempercepat evaluasi komparasi props.
  • Gunakan getItemType pada FlashList: Jika list memiliki variasi layout yang berbeda (misalnya item teks biasa vs item bergambar), sediakan getItemType. Hal ini mencegah layout mismatch ketika cell yang didaur ulang memiliki hierarki view native yang berbeda secara struktural.
  • Stabilkan Handler dengan Parameter ID: Jangan membuat inline arrow function di dalam renderItem (misal: onPress={() => handleToggle(item.id)}). Oper handler stabil dan biarkan komponen anak mengeksekusi callback dengan ID miliknya sendiri.
  • Tentukan estimatedItemSize yang Realistis: Pada FlashList, estimasi ukuran yang akurat mengurangi komputasi layout dinamis dan mencegah visual jump selama scroll cepat.