kodingup.camp

Beranda/React Mastery: Styling, Pola Komponen, Data & Optimasi

Siklus Hidup dan Sinkronisasi Rendering

Modul 13 dari 135 menit bacaLifecycle & Rendering Synchronization

Isi modul

    Kenapa ini penting

    Kamu sudah sering menulis useEffect, tapi mungkin masih menganggapnya sebagai "kode yang jalan setelah render". Untuk menulis effect yang benar, apalagi yang terhubung ke sistem luar seperti WebSocket, timer, library peta, atau API browser, kamu perlu gambaran utuh siklus hidup komponen: kapan ia muncul (mount), berubah (update), dan hilang (unmount). Kamu juga perlu tahu kenapa di development effect-mu terlihat berjalan dua kali, dan kapan harus memakai useLayoutEffect ketimbang useEffect. Banyak bug sulit di aplikasi React (memory leak, koneksi ganda, tampilan berkedip) berasal dari pemahaman siklus yang kurang tepat.

    Konsep inti

    Mount, update, unmount dengan hooks

    Fase Artinya Dengan hooks
    Mount Komponen pertama kali muncul di layar Effect dijalankan setelah render pertama
    Update Props/state berubah, komponen render ulang Jika dependency berubah: cleanup effect lama dijalankan, lalu effect baru
    Unmount Komponen dihapus dari layar Cleanup effect terakhir dijalankan

    Dokumentasi React menyarankan cara berpikir yang lebih tepat: jangan memikirkan effect dari sudut pandang siklus komponen, tetapi dari sudut pandang sinkronisasi. Setiap effect menjawab dua pertanyaan: bagaimana mulai sinkron (setup) dan bagaimana berhenti sinkron (cleanup). React akan menjalankan setup dan cleanup sesering yang diperlukan agar sistem luar selalu cocok dengan props/state terbaru.

    Contoh: effect koneksi chat dengan dependency [roomId]. Saat mount, terhubung ke room "umum". Saat roomId berubah ke "kerja", React memutus koneksi "umum" (cleanup) lalu terhubung ke "kerja" (setup). Saat unmount, putuskan koneksi "kerja".

    Analogi: effect itu seperti menyalakan keran air untuk mengisi ember tertentu. Cleanup adalah mematikan keran. Kalau ember diganti, kamu matikan keran ke ember lama sebelum mengarahkan ke ember baru. Lupa mematikan = air terbuang (memory leak).

    StrictMode: render dan effect dua kali di development

    Template Vite membungkus aplikasi dengan <StrictMode>. Menurut dokumentasi React, di development saja, StrictMode: - Me-render komponen satu kali ekstra untuk menemukan render yang tidak murni (misalnya mengubah array props). - Menjalankan satu siklus setup + cleanup ekstra untuk setiap effect, untuk menemukan effect yang lupa cleanup. - Menjalankan siklus ekstra serupa untuk ref callback. - Memeriksa pemakaian API yang sudah usang.

    Semua pemeriksaan ini tidak berlaku di build production. Jadi kalau kamu melihat console.log di effect muncul dua kali atau request terkirim dua kali di development, itu wajar. Jangan "memperbaikinya" dengan menghapus StrictMode atau membuat flag sudahJalan di useRef. Perbaiki dengan menulis cleanup yang benar: kalau setup → cleanup → setup menghasilkan kondisi sama dengan sekali setup, effect kamu benar.

    useEffect vs useLayoutEffect

    Urutan kerja React setelah state berubah: render (memanggil komponen) → commit (mengubah DOM) → browser paint (menggambar ke layar).

    Karena useLayoutEffect memblokir paint, pemakaian berlebihan membuat aplikasi terasa lambat. Mulai dengan useEffect, pindah ke useLayoutEffect hanya jika ada kedipan yang terlihat.

    Sinkronisasi dengan sistem luar

    "Sistem luar" adalah apa pun yang tidak dikendalikan React: window event, setInterval, WebSocket, document.title, widget library non-React (peta, chart), elemen <video>. Polanya selalu sama:

    1. Setup di effect (pasang listener, buat koneksi, mulai timer).
    2. Kembalikan cleanup yang membalik setup (lepas listener, tutup koneksi, hentikan timer).
    3. Isi dependency array dengan semua nilai reaktif (props/state) yang dipakai.

    Sebaliknya, jangan pakai effect untuk hal yang bukan sinkronisasi dengan sistem luar: menghitung data turunan (hitung saja saat render, modul 3) atau merespons klik user (taruh di event handler).

    Contoh kode

    Melihat urutan mount, update, unmount di console:

    import { useEffect, useState } from 'react';
    
    function Jam({ zona }) {
      const [waktu, setWaktu] = useState(() => new Date());
    
      useEffect(() => {
        console.log(`setup: mulai timer (${zona})`);
        const id = setInterval(() => setWaktu(new Date()), 1000);
        return () => {
          console.log(`cleanup: hentikan timer (${zona})`);
          clearInterval(id);
        };
      }, [zona]);
    
      return <p>{waktu.toLocaleTimeString('id-ID', { timeZone: zona })} ({zona})</p>;
    }
    
    export default function App() {
      const [tampil, setTampil] = useState(true);
      const [zona, setZona] = useState('Asia/Jakarta');
    
      return (
        <>
          <button onClick={() => setTampil(!tampil)}>{tampil ? 'Sembunyikan' : 'Tampilkan'}</button>
          <button onClick={() => setZona(zona === 'Asia/Jakarta' ? 'Asia/Jayapura' : 'Asia/Jakarta')}>
            Ganti zona
          </button>
          {tampil && <Jam zona={zona} />}
        </>
      );
    }
    
    // Mount (development + StrictMode):
    // → setup: mulai timer (Asia/Jakarta)
    // → cleanup: hentikan timer (Asia/Jakarta)   ← siklus ekstra StrictMode
    // → setup: mulai timer (Asia/Jakarta)
    // Klik "Ganti zona" (update):
    // → cleanup: hentikan timer (Asia/Jakarta)
    // → setup: mulai timer (Asia/Jayapura)
    // Klik "Sembunyikan" (unmount):
    // → cleanup: hentikan timer (Asia/Jayapura)
    

    Sinkronisasi dengan API browser dan document.title:

    import { useEffect, useState } from 'react';
    
    export function StatusOnline() {
      const [online, setOnline] = useState(() => navigator.onLine);
    
      useEffect(() => {
        const naik = () => setOnline(true);
        const turun = () => setOnline(false);
        window.addEventListener('online', naik);
        window.addEventListener('offline', turun);
        return () => {
          window.removeEventListener('online', naik);
          window.removeEventListener('offline', turun);
        };
      }, []);
    
      useEffect(() => {
        document.title = online ? 'Toko Saya' : '(Offline) Toko Saya';
      }, [online]);
    
      return <p>{online ? '🟢 Online' : '🔴 Offline'}</p>;
    }
    // matikan Wi-Fi → teks "🔴 Offline" dan judul tab "(Offline) Toko Saya"
    

    useLayoutEffect untuk mengukur DOM sebelum paint (tooltip):

    import { useLayoutEffect, useRef, useState } from 'react';
    
    export function Tooltip({ teks, children }) {
      const ref = useRef(null);
      const [posisi, setPosisi] = useState('atas');
    
      useLayoutEffect(() => {
        const kotak = ref.current.getBoundingClientRect();
        // kalau tooltip keluar dari atas layar, pindah ke bawah SEBELUM user melihatnya
        if (kotak.top < 0) setPosisi('bawah');
      }, []);
    
      return (
        <span style={{ position: 'relative' }}>
          {children}
          <span
            ref={ref}
            style={{
              position: 'absolute',
              left: 0,
              [posisi === 'atas' ? 'bottom' : 'top']: '100%',
              background: '#111',
              color: '#fff',
              padding: '4px 8px',
              borderRadius: 4,
              whiteSpace: 'nowrap',
            }}
          >
            {teks}
          </span>
        </span>
      );
    }
    // dengan useEffect, tooltip sempat terlihat "melompat" dari atas ke bawah; dengan useLayoutEffect tidak
    

    Membungkus library non-React (contoh dengan API <dialog> bawaan browser):

    import { useEffect, useRef } from 'react';
    
    export function Dialog({ terbuka, onClose, children }) {
      const ref = useRef(null);
    
      // sinkronkan props "terbuka" (React) dengan API imperatif dialog (sistem luar)
      useEffect(() => {
        const dialog = ref.current;
        if (terbuka) dialog.showModal();
        else dialog.close();
      }, [terbuka]);
    
      return (
        <dialog ref={ref} onClose={onClose}>
          {children}
          <button onClick={onClose}>Tutup</button>
        </dialog>
      );
    }
    

    Kesalahan umum

    1. Mematikan StrictMode karena effect jalan dua kali. Itu justru menyembunyikan bug cleanup. Tulis cleanup yang benar; di production effect hanya berjalan sekali per mount.
    2. Lupa cleanup. Listener, interval, dan koneksi yang tidak dilepas tetap hidup setelah komponen hilang, menyebabkan memory leak dan error "update state pada komponen yang sudah unmount".
    3. Memakai useLayoutEffect sebagai default. Ia memblokir paint sehingga UI terasa lebih lambat. Pakai useEffect, kecuali kamu perlu mengukur DOM sebelum tampil.
    4. Memakai effect untuk data turunan atau event user. useEffect(() => setTotal(a + b), [a, b]) menambah render ekstra. Hitung const total = a + b saat render; respons klik taruh di handler.
    5. Dependency array tidak jujur. Menghapus dependency agar effect "tidak berjalan ulang" membuat effect memakai nilai basi. Kalau effect berjalan terlalu sering, ubah kodenya (misalnya pakai updater function setX((x) => ...)), bukan berbohong ke dependency array.

    Latihan

    1. Mudah, Buat komponen Stopwatch dengan tombol Mulai/Berhenti yang memakai setInterval dan cleanup yang benar. Pastikan di StrictMode angka tidak bertambah dua kali lebih cepat.
    2. Sedang, Buat hook useDocumentTitle(judul) yang mengubah judul tab dan mengembalikan judul sebelumnya saat komponen unmount.
    3. Menantang, Buat komponen KotakUkuran yang menampilkan lebar dan tinggi elemen <div> miliknya sendiri, dan ikut berubah saat jendela di-resize. Gunakan ResizeObserver (sistem luar) dengan cleanup disconnect().
    Lihat solusi
    // 1.
    import { useEffect, useState } from 'react';
    
    export function Stopwatch() {
      const [detik, setDetik] = useState(0);
      const [jalan, setJalan] = useState(false);
    
      useEffect(() => {
        if (!jalan) return;
        const id = setInterval(() => setDetik((d) => d + 1), 1000);
        return () => clearInterval(id); // StrictMode: interval ekstra langsung dibersihkan
      }, [jalan]);
    
      return (
        <>
          <p>{detik} detik</p>
          <button onClick={() => setJalan(!jalan)}>{jalan ? 'Berhenti' : 'Mulai'}</button>
        </>
      );
    }
    
    // 2.
    import { useEffect } from 'react';
    
    export function useDocumentTitle(judul) {
      useEffect(() => {
        const sebelumnya = document.title;
        document.title = judul;
        return () => {
          document.title = sebelumnya;
        };
      }, [judul]);
    }
    // useDocumentTitle('Keranjang (3)') → tab berjudul "Keranjang (3)"; unmount → judul kembali
    
    // 3.
    import { useEffect, useRef, useState } from 'react';
    
    export function KotakUkuran() {
      const ref = useRef(null);
      const [ukuran, setUkuran] = useState({ w: 0, h: 0 });
    
      useEffect(() => {
        const observer = new ResizeObserver(([entry]) => {
          const { width, height } = entry.contentRect;
          setUkuran({ w: Math.round(width), h: Math.round(height) });
        });
        observer.observe(ref.current);
        return () => observer.disconnect();
      }, []);
    
      return (
        <div ref={ref} style={{ width: '50%', padding: 16, border: '1px solid #ccc' }}>
          {ukuran.w} × {ukuran.h} px
        </div>
      );
    }
    // resize jendela → angka lebar berubah mengikuti
    

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.