Pada New Architecture React Native (Fabric), rendering UI memanfaatkan Concurrent React. Engine dapat menjeda (yield), memprioritaskan, atau membatalkan proses render thread guna menjaga responsivitas frame rate (misalnya saat gestur scroll 120 FPS). Fitur ini menghadirkan tantangan baru: UI tearing.

UI tearing terjadi ketika dua atau lebih komponen membaca data dari external store yang sama pada siklus render yang sama, tetapi menampilkan nilai dari versi state yang berbeda. Artikel ini membahas mekanisme terjadinya state mismatch tersebut dan cara memperbaikinya menggunakan API useSyncExternalStore.

Penyebab UI Tearing pada Fabric Concurrent Rendering

Sebelum Fabric, React Native menjalankan reconciler secara sinkron di JS thread. Ketika state diperbarui, seluruh pohon komponen yang terdampak dirender dari atas ke bawah tanpa interupsi. Model ini tidak efisien, tetapi mencegah tearing karena pembacaan data selalu konsisten.

Pada Concurrent React di Fabric, siklus render dapat dipecah menjadi beberapa irisan waktu (time-slicing). Apabila aplikasi mengandalkan store di luar siklus hidup React (seperti custom Redux-like store, MMKV listener, atau state manager berbasis pub/sub manual), mutasi dapat terjadi di tengah-tengah eksekusi render pohon komponen.

  1. Komponen A membaca nilai store v1 dan mulai me-render.
  2. React menjeda render Komponen B untuk memproses event native prioritas tinggi (misalnya gestur input pengguna).
  3. Sebuah network event atau asynchronous timer memperbarui external store ke v2.
  4. React melanjutkan render Komponen B. Komponen B membaca store dan mendapatkan v2.
  5. Komit selesai: UI menampilkan data Komponen A (v1) dan Komponen B (v2) secara bersamaan. Inilah UI tearing.

Pola Rentan: useState dan useEffect

Pola klasik integrasi external store umumnya menggunakan useState untuk menampung nilai lokal dan useEffect untuk berlangganan listener. Pola ini rentan terhadap tearing di lingkungan concurrent.

// store.ts - Global store sederhana
type Listener = () => void;

let state = { count: 0 };
const listeners = new Set<Listener>();

export const counterStore = {
  getSnapshot: () => state,
  increment: () => {
    state = { count: state.count + 1 };
    listeners.forEach((listener) => listener());
  },
  subscribe: (listener: Listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
};

Komponen yang menggunakan pola useEffect di bawah ini berisiko menghasilkan render mismatch:

// CounterLabelVulnerable.tsx - Anti-pattern
import React, { useState, useEffect } from 'react';
import { Text } from 'react-native';
import { counterStore } from './store';

export function CounterLabelVulnerable() {
  // Rentan: Membaca snapshot langsung saat render awal
  const [value, setValue] = useState(counterStore.getSnapshot());

  useEffect(() => {
    // Passive effect baru berjalan SETELAH commit phase selesai
    const unsubscribe = counterStore.subscribe(() => {
      setValue(counterStore.getSnapshot());
    });
    return unsubscribe;
  }, []);

  return <Text>Count: {value.count}</Text>;
}

Masalah utama kode di atas: hook useEffect dieksekusi secara asinkron setelah render selesai. Jika store termutasi di tengah concurrent render, komponen melewatkan sinkronisasi snapshot awal, menyebabkan render visual yang tidak konsisten antar-komponen.

Solusi: Mengintegrasikan useSyncExternalStore

React 18 memperkenalkan hook bawaan useSyncExternalStore. Hook ini memaksa pembacaan external store dilakukan secara sinkron selama render berlangsung. Jika store bermutasi sebelum commit selesai, React membatalkan render concurrent yang sedang berjalan dan mengulanginya secara sinkron untuk memastikan konsistensi visual.

// CounterLabelSafe.tsx - Pola yang Benar
import React, { useSyncExternalStore } from 'react';
import { Text } from 'react-native';
import { counterStore } from './store';

export function CounterLabelSafe() {
  const state = useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot
  );

  return <Text>Count: {state.count}</Text>;
}

Untuk kasus selector data primitif, teruskan fungsi selector melalui closure atau utility hook:

// useStoreSelector.ts
import { useCallback, useSyncExternalStore } from 'react';
import { counterStore } from './store';

export function useCounterCount(): number {
  const getCount = useCallback(() => counterStore.getSnapshot().count, []);
  
  return useSyncExternalStore(
    counterStore.subscribe,
    getCount
  );
}

Mencegah Infinite Loop Akibat Unstable Snapshot Reference

Kesalahan fatal yang sering terjadi saat mengimplementasikan useSyncExternalStore adalah mengembalikan objek baru setiap kali getSnapshot dipanggil.

React membandingkan hasil kembalian getSnapshot saat ini dengan pemanggilan sebelumnya menggunakan algoritma Object.is(). Jika referensi objek selalu berubah, React menganggap store telah diperbarui terus-menerus dan memicu loop render tak berujung (Maximum update depth exceeded).

// CONTOH KELIRU: Mengembalikan referensi baru setiap pemanggilan
const getSnapshot = () => {
  return { count: counterStore.getSnapshot().count }; // Referensi selalu baru!
};

// useSyncExternalStore akan crash dalam infinite loop
const data = useSyncExternalStore(counterStore.subscribe, getSnapshot);

Strategi Pencegahan:

  • Kembalikan Nilai Primitif: Jika selector mengambil string, number, atau boolean, Object.is() membandingkan nilai berdasarkan value equality, bukan pointer memory.
  • Immutable State Architecture: Pastikan external store mengembalikan referensi root state yang sama jika tidak ada mutasi data. Mutasi harus memperbarui referensi secara immutabel (structural sharing).
  • Memoized Snapshot: Jika perlu mentransformasi data turunan dalam bentuk object/array, simpan referensi hasil transformasi dalam cache selama raw snapshot belum berubah.

Verifikasi UI Tearing di Lingkungan Fabric

UI tearing sulit dideteksi pada unit test biasa karena berkaitan langsung dengan thread scheduling dan timing interupsi. Berikut metode pengujian praktis:

1. Stress-Testing dengan Fast Timers

Gunakan komponen ganda (misal 50–100 node teks) yang membaca external store secara bersamaan. Pancing pembaruan store interval pendek (1–2 ms via setInterval atau native event emitter) berbarengan dengan scroll list yang berat di perangkat fisik.

2. Verifikasi via React DevTools Profiler

Buka React DevTools yang terhubung ke aplikasi Fabric:

  • Buka tab Profiler dan aktifkan opsi Record why each component rendered.
  • Jalankan profile session saat interaksi berlangsung.
  • Amati status Commit. Jika terjadi de-optimasi sinkron yang masif atau waterfall render beruntun pada komponen yang langganan ke store, verifikasi apakah referensi snapshot Anda stabil atau apakah store terlalu sering membakar re-render yang tidak terkoordinasi.

Penerapan useSyncExternalStore secara disiplin memastikan aplikasi yang berjalan di atas React Native New Architecture aman dari rendering artifacts, menjaga akurasi representasi state pada layar secara absolut.