REACT REACT-HOOKS STATE-MANAGEMENT FRONTEND WEB-DEVELOPMENT PERFORMANCE CONCURRENCY BROWSER-API ADVANCED-REACT JAVASCRIPT REACT18

Menguasai useSyncExternalStore: Menjembatani React dengan State Eksternal Secara Aman dan Efisien

⏱️ 9 menit baca
👨‍💻

1. Pendahuluan

Sebagai developer React, kita terbiasa mengelola state dengan useState, useReducer, atau Context API. Namun, bagaimana jika aplikasi React kita perlu berinteraksi dengan state yang berada di luar kontrol React sepenuhnya? Misalnya, data di localStorage, BroadcastChannel, atau state dari library pihak ketiga yang tidak dirancang khusus untuk React.

Inilah tantangannya: React modern, terutama dengan fitur Concurrent Rendering yang diperkenalkan di React 18, dapat menjadwalkan, menginterupsi, dan memulai ulang proses rendering. Jika state eksternal berubah di tengah-tengah proses ini, UI Anda bisa berakhir dalam keadaan inkonsisten – atau yang lebih dikenal dengan istilah “tearing”. Bayangkan Anda sedang membaca sebuah buku, lalu tiba-tiba ada yang mengubah beberapa halaman di tengah-tengah, sehingga Anda melihat campuran teks lama dan baru secara bersamaan. Itu adalah “tearing” di UI!

Untuk mengatasi masalah ini secara aman dan efisien, React memperkenalkan hook baru di React 18: useSyncExternalStore. Hook ini adalah jembatan yang kokoh antara dunia React yang reaktif dan state eksternal yang mutable, memastikan aplikasi Anda tetap konsisten dan berperforma tinggi.

Mari kita selami lebih dalam!

2. Memahami Masalah “Tearing” di Concurrent React

Sebelum useSyncExternalStore ada, developer biasanya mengelola state eksternal menggunakan useEffect untuk berlangganan (subscribe) perubahan dan useState untuk menyimpan nilai terbaru. Pendekatan ini bekerja dengan baik untuk sebagian besar kasus di React versi lama atau aplikasi dengan rendering sinkron.

Namun, dengan Concurrent React, situasinya berbeda. Concurrent React memungkinkan React untuk bekerja di latar belakang tanpa memblokir UI, atau bahkan menginterupsi rendering yang sedang berjalan jika ada prioritas yang lebih tinggi.

Bagaimana Tearing Terjadi?

  1. React memulai render pertama untuk komponen Anda. Di tengah proses ini, ia membaca nilai dari state eksternal (misalnya, localStorage).
  2. Tiba-tiba, state eksternal berubah (misalnya, tab browser lain mengubah localStorage atau ada event dari BroadcastChannel).
  3. React menginterupsi render pertama dan memulai render kedua (atau melanjutkan render pertama dengan data yang “kadaluarsa”).
  4. Komponen Anda akhirnya di-render ke DOM, tetapi bagian-bagian yang berbeda dari UI mungkin telah membaca nilai state eksternal pada waktu yang sedikit berbeda, menghasilkan UI yang menampilkan campuran nilai lama dan baru. Inilah yang disebut “tearing”.

useState atau useEffect tidak bisa menjamin bahwa semua pembacaan state eksternal dalam satu proses render akan konsisten, karena mereka tidak terintegrasi dengan mekanisme penjadwalan Concurrent React. useSyncExternalStore hadir untuk memecahkan masalah fundamental ini.

3. Anatomi useSyncExternalStore: Tiga Pilar Konsistensi

useSyncExternalStore bekerja dengan cara yang sedikit berbeda dari hook lain. Ia meminta Anda untuk menyediakan tiga fungsi kunci yang memungkinkan React berinteraksi dengan store eksternal Anda secara konsisten:

const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?);

Mari kita bedah masing-masing argumen:

  1. subscribe(callback)

    • Tugas: Memberi tahu React bagaimana cara berlangganan (subscribe) ke perubahan di store eksternal Anda.
    • Input: Menerima satu argumen, callback, yang harus Anda panggil setiap kali state eksternal berubah.
    • Output: Harus mengembalikan fungsi unsubscribe yang akan dipanggil React ketika komponen tidak lagi membutuhkan langganan (misalnya, komponen unmount).
    • Penting: React akan memanggil fungsi callback yang Anda berikan untuk memicu re-render pada komponen yang menggunakan hook ini, sehingga komponen selalu mendapatkan nilai terbaru.
  2. getSnapshot()

    • Tugas: Memberi tahu React bagaimana cara membaca nilai state saat ini dari store eksternal Anda.
    • Input: Tidak menerima argumen.
    • Output: Harus mengembalikan nilai state saat ini dari store eksternal.
    • Penting: Fungsi ini harus bersifat idempotent. Artinya, jika state eksternal tidak berubah, getSnapshot harus mengembalikan nilai yang sama persis (secara referensi) untuk mencegah re-render yang tidak perlu. React akan membandingkan nilai yang dikembalikan oleh getSnapshot untuk menentukan apakah ada perubahan dan apakah komponen perlu di-render ulang.
  3. getServerSnapshot() (Opsional, untuk Server-Side Rendering)

    • Tugas: Memberi tahu React bagaimana cara membaca nilai state awal dari store eksternal saat aplikasi di-render di server (SSR).
    • Input: Tidak menerima argumen.
    • Output: Harus mengembalikan nilai state awal dari store eksternal.
    • Penting: Jika Anda menggunakan SSR, fungsi ini wajib ada. Ini akan digunakan oleh React untuk mengisi nilai awal state Anda di server. Jika nilai ini tidak cocok dengan nilai awal di klien, Anda bisa mengalami masalah hydration mismatch, di mana UI yang di-render di server berbeda dengan yang diharapkan di klien. Jika store Anda tidak memiliki nilai di server (misalnya, hanya ada di browser seperti localStorage), Anda bisa mengembalikan nilai default (misalnya null atau undefined).

4. useSyncExternalStore dalam Praktik: Menggunakan LocalStorage

Mari kita ambil contoh paling umum: mengelola tema (dark/light mode) yang disimpan di localStorage.

💡 Use Case: Kita ingin komponen React kita secara otomatis bereaksi terhadap perubahan theme di localStorage, baik dari dalam aplikasi itu sendiri maupun dari tab browser lain yang mengakses localStorage yang sama.

// hooks/useThemeLocalStorage.js
import { useSyncExternalStore } from 'react';

const STORAGE_KEY = 'app-theme';

// Fungsi untuk mendapatkan nilai tema dari localStorage
function getSnapshot() {
  return localStorage.getItem(STORAGE_KEY);
}

// Fungsi untuk berlangganan perubahan localStorage
function subscribe(callback) {
  // Event 'storage' dipicu ketika localStorage di tab lain berubah
  window.addEventListener('storage', callback);
  // Mengembalikan fungsi untuk membersihkan langganan
  return () => {
    window.removeEventListener('storage', callback);
  };
}

// Custom Hook untuk mengakses tema
export function useThemeLocalStorage() {
  // Menggunakan useSyncExternalStore
  const theme = useSyncExternalStore(subscribe, getSnapshot, () => null); // getServerSnapshot bisa null karena localStorage hanya ada di browser

  // Fungsi untuk mengubah tema
  const setTheme = (newTheme) => {
    localStorage.setItem(STORAGE_KEY, newTheme);
    // Panggil callback secara manual agar React di tab ini juga tahu ada perubahan
    // Ini penting karena event 'storage' TIDAK dipicu di tab yang sama yang melakukan perubahan
    // Kita bisa membuat BroadcastChannel atau memanggil subscribe callback secara langsung
    // Untuk kesederhanaan, kita bisa memicu re-render secara manual di sini
    // Namun, cara yang lebih robust adalah dengan membuat "external store" yang memanggil callback
    // (akan kita bahas di bagian selanjutnya)
    window.dispatchEvent(new Event('storage')); // Ini akan memicu event di tab lain, tapi tidak di tab ini
  };

  return [theme, setTheme];
}
// App.js atau Komponen Lain
import React from 'react';
import { useThemeLocalStorage } from './hooks/useThemeLocalStorage';

function ThemeSwitcher() {
  const [theme, setTheme] = useThemeLocalStorage();

  React.useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme || 'light');
  }, [theme]);

  const toggleTheme = () => {
    setTheme(theme === 'dark' ? 'light' : 'dark');
  };

  return (
    <div style={{ padding: '20px', background: theme === 'dark' ? '#333' : '#f0f0f0', color: theme === 'dark' ? '#eee' : '#333' }}>
      <h1>Aplikasi Anda</h1>
      <p>Tema saat ini: **{theme || 'light'}**</p>
      <button onClick={toggleTheme}>Ganti Tema</button>
      <p>Coba buka tab baru dan ubah tema di salah satu tab. Perhatikan bagaimana tab lain juga ikut berubah!</p>
    </div>
  );
}

export default ThemeSwitcher;

📌 Catatan Penting untuk localStorage: Event storage hanya dipicu ketika localStorage diubah oleh tab browser lain. Jika Anda mengubah localStorage di tab yang sama, event storage tidak akan dipicu. Untuk mengatasi ini, Anda perlu mekanisme tambahan (seperti BroadcastChannel atau memanggil callback secara manual di dalam fungsi setTheme dari store kustom yang kita buat di bagian selanjutnya) agar komponen di tab yang sama juga segera update.

5. useSyncExternalStore untuk State Management Kustom (Vanilla JS Store)

Pendekatan yang lebih robust untuk mengelola state eksternal, terutama jika Anda ingin kontrol lebih, adalah dengan membuat “store” vanilla JavaScript Anda sendiri. Store ini akan memiliki metode untuk mendapatkan state, mengubah state, dan berlangganan perubahan.

🎯 Tujuan: Membuat store sederhana yang bisa diakses dan di-subscribe oleh komponen React, tanpa masalah tearing.

// stores/themeStore.js
let currentTheme = localStorage.getItem('app-theme') || 'light';
const listeners = new Set();

const themeStore = {
  // 1. Fungsi untuk mendapatkan state saat ini
  getSnapshot() {
    return currentTheme;
  },

  // 2. Fungsi untuk berlangganan perubahan
  subscribe(callback) {
    listeners.add(callback);
    // Tambahan: Langganan juga ke event 'storage' browser untuk sync lintas tab
    const handleStorageChange = () => {
      const newTheme = localStorage.getItem('app-theme') || 'light';
      if (newTheme !== currentTheme) {
        currentTheme = newTheme;
        callback(); // Panggil callback jika ada perubahan dari tab lain
      }
    };
    window.addEventListener('storage', handleStorageChange);

    return () => {
      listeners.delete(callback);
      window.removeEventListener('storage', handleStorageChange);
    };
  },

  // 3. Fungsi untuk mengubah state
  setTheme(newTheme) {
    if (newTheme === currentTheme) return; // Hindari update jika tidak ada perubahan
    currentTheme = newTheme;
    localStorage.setItem('app-theme', newTheme);
    // Memberi tahu semua listener bahwa state telah berubah
    listeners.forEach(listener => listener());
  },

  // 4. (Opsional) Inisialisasi atau fungsi lain
  // ...
};

export default themeStore;
// hooks/useTheme.js
import { useSyncExternalStore } from 'react';
import themeStore from '../stores/themeStore';

export function useTheme() {
  const theme = useSyncExternalStore(themeStore.subscribe, themeStore.getSnapshot, themeStore.getSnapshot); // getServerSnapshot bisa sama dengan getSnapshot jika store bisa diinisialisasi di server

  const setTheme = themeStore.setTheme;

  return [theme, setTheme];
}

Dengan pendekatan ini, themeStore.setTheme akan memanggil semua listener, termasuk yang didaftarkan oleh useSyncExternalStore di komponen React, sehingga komponen akan re-render dengan nilai terbaru secara konsisten, baik dari perubahan di tab yang sama maupun tab lain (melalui event storage).

Keuntungan pendekatan store kustom:

6. Best Practices dan Pertimbangan

useSyncExternalStore adalah alat yang sangat kuat, tetapi seperti semua alat canggih, ada beberapa hal yang perlu dipertimbangkan: