Lompat ke konten utama
vourdev
Kembali ke Blog

Tutorial

5 Kesalahan Pakai React useEffect yang Diam-diam Bikin Web Lu Lemot

Pelajari 5 anti-pattern useEffect yang memicu double re-render, race condition, dan memory leak, lengkap dengan solusinya.

Gambar Cover 5 Kesalahan Pakai React useEffect yang Diam-diam Bikin Web Lu Lemot
Ditulis olehvourdev11 menit baca

Berapa kali lu nemu komponen React yang responsnya kerasa "berat", input text-nya agak laggy, atau kipas laptop mendadak muter kencang pas buka halaman tertentu? Pas lu buka React DevTools Profiler, layarnya penuh sama baris hijau dan kuning: puluhan re-render terjadi beruntun hanya untuk satu klik tombol sederhana.

Tersangka utamanya hampir selalu sama: penggunaan `useEffect` yang serampangan.

Waktu React Hooks pertama kali rilis di versi 16.8, banyak developer langsung menganggap useEffect sebagai pengganti saklek untuk componentDidMount, componentDidUpdate, dan componentWillUnmount. Pola pikir lifecycle era class component ini dibawa mentah-mentah ke paradigma deklaratif React modern. Akibatnya, useEffect sering dijadikan "tempat sampah serbaguna" untuk sinkronisasi state, perhitungan data, manipulasi DOM, hingga event handling.

Di artikel ini, kita bakal bongkar 5 kesalahan paling umum pemakaian useEffect yang bikin performa web lu anjlok, lengkap dengan analisis teknis mengapa hal itu terjadi dan bagaimana cara memperbaikinya dengan pendekatan modern.


1. Menghitung Derived State Menggunakan useEffect

Profiler React DevTools menunjukkan waterfall re-render yang tidak perlu
Waterfall render pass yang menumpuk akibat rantai useEffect. · Lautaro Andreani (Unsplash)

Ini adalah dosa nomor satu yang paling sering ditemui di codebase produksi. Developer punya satu state utama (misalnya daftar produk), lalu mereka membuat state kedua (daftar produk terfilter), dan menghubungkan keduanya lewat useEffect.

Mari kita lihat contoh implementasi yang keliru:

tsx
// ❌ ANTI-PATTERN: Menyimpan derived state dan menyinkronkannya lewat useEffect
import { useState, useEffect } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}

export function ProductListBad({ products }: { products: Product[] }) {
  const [selectedCategory, setSelectedCategory] = useState<string>('all');
  const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);

  useEffect(() => {
    // Memfilter data dan memicu render ulang
    if (selectedCategory === 'all') {
      setFilteredProducts(products);
    } else {
      setFilteredProducts(products.filter((p) => p.category === selectedCategory));
    }
  }, [products, selectedCategory]);

  return (
    <div>
      <select onChange={(e) => setSelectedCategory(e.target.value)}>
        <option value="all">Semua</option>
        <option value="electronics">Elektronik</option>
      </select>
      <ul>
        {filteredProducts.map((p) => (
          <li key={p.id}>{p.name} - Rp{p.price}</li>
        ))}
      </ul>
    </div>
  );
}

Mengapa Kode di Atas Merusak Performa?

Mari kita bedah siklus eksekusi di atas:

  1. User mengganti kategori melalui dropdown.
  2. setSelectedCategory dipanggil. React melakukan render pertama. Di fase ini, filteredProducts masih berisi data lama.
  3. DOM di-commit dan browser menggambar frame pertama (stale UI).
  4. Selesai render, React mengeksekusi useEffect.
  5. Di dalam effect, setFilteredProducts dipanggil dengan data baru.
  6. React dipaksa melakukan render kedua (second render pass) untuk menampilkan data hasil filter.

Lu baru saja membuat React me-render komponen yang sama dua kali untuk setiap perubahan interaksi user. Kalau hierarki komponen lu dalam atau punya ratusan item, layout shift dan penurunan frame rate (FPS drop) bakal langsung terasa.

Solusi: Hitung Langsung Saat Render (Derived State)

Data yang bisa dihitung dari props atau state yang sudah ada tidak boleh disimpan di state terpisah.

Perbandingan Alur Render: useEffect vs Perhitungan LangsungAnti-Pattern: useState +useEffectState berubahRender 1: Komponen direnderdengan data lamaDOM diupdate ke browser (StaleFrame)useEffect berjalan & panggilsetState baruRender 2: Komponen dirender ulangSolusi: Direct Render /useMemoState berubahHitung variabel turunan secarainlineRender 1: Komponen langsungdapat data finalDOM diupdate sekali (Zero StaleFrame)
Perbandingan Alur Render: useEffect vs Perhitungan Langsung

Berikut perbaikan kodenya:

tsx
// ✅ BEST PRACTICE: Derived state dihitung langsung saat proses rendering
import { useState, useMemo } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}

export function ProductListGood({ products }: { products: Product[] }) {
  const [selectedCategory, setSelectedCategory] = useState<string>('all');

  // Jika komputasi ringan (< 1000 item), hitung langsung:
  const filteredProducts = selectedCategory === 'all'
    ? products
    : products.filter((p) => p.category === selectedCategory);

  // Jika komputasi sangat berat (ribuan item atau filtering kompleks), gunakan useMemo:
  // const filteredProducts = useMemo(() => {
  //   return selectedCategory === 'all'
  //     ? products
  //     : products.filter((p) => p.category === selectedCategory);
  // }, [products, selectedCategory]);

  return (
    <div>
      <select onChange={(e) => setSelectedCategory(e.target.value)}>
        <option value="all">Semua</option>
        <option value="electronics">Elektronik</option>
      </select>
      <ul>
        {filteredProducts.map((p) => (
          <li key={p.id}>{p.name} - Rp{p.price}</li>
        ))}
      </ul>
    </div>
  );
}
Aturan Praktis: Jangan pernah menggunakan useEffect hanya untuk mengubah state lokal berdasarkan state lain. Hitung langsung nilainya di badan fungsi komponen (render body).

2. Menangani User Event di dalam useEffect

Sering kali developer ingin menjalankan aksi tertentu setelah user menekan tombol—misalnya mengirim analitik, menampilkan toast notifikasi, atau mengarahkan halaman—tetapi mereka malah menyimpannya di state lalu "menangkapnya" via useEffect.

tsx
// ❌ ANTI-PATTERN: Menggunakan state flag untuk mentrigger side effect di useEffect
import { useState, useEffect } from 'react';

export function CheckoutButtonBad({ cartId }: { cartId: string }) {
  const [isSubmitted, setIsSubmitted] = useState(false);

  useEffect(() => {
    if (isSubmitted) {
      // Mengirim analitik dan notifikasi
      navigator.sendBeacon('/api/analytics', JSON.stringify({ event: 'checkout_click', cartId }));
      showNotification('Sedang memproses pesanan...');
      setIsSubmitted(false); // Mereset state (memicu render ulang ke-3!)
    }
  }, [isSubmitted, cartId]);

  return (
    <button onClick={() => setIsSubmitted(true)}>
      Bayar Sekarang
    </button>
  );
}

Mengapa Pola Ini Buruk?

Kode ini memecah alur logika yang seharusnya imperatif (reaksi langsung terhadap aksi klik) menjadi rantai reaktif yang berputar-putar:

  • Klik tombol -> ubah state isSubmitted jadi true (Render 1).
  • Effect mendeteksi perubahan -> jalankan analitik -> ubah state isSubmitted jadi false (Render 2).
  • Komponen harus melewati dua siklus render hanya untuk mengeksekusi satu fungsi pengiriman analitik.
  • Logika bisnis jadi sulit di-trace karena tersebar di antara handler dan effect.

Solusi: Pindahkan Logika ke Event Handler

useEffect diciptakan untuk melakukan sinkronisasi komponen dengan sistem eksternal (external system synchronization) berdasarkan state aplikasi saat ini, bukan untuk merespons interaksi sesaat dari user.

tsx
// ✅ BEST PRACTICE: Taruh side effect langsung di dalam Event Handler
export function CheckoutButtonGood({ cartId }: { cartId: string }) {
  const handleCheckout = () => {
    // 1. Eksekusi side-effect imperatif seketika
    navigator.sendBeacon('/api/analytics', JSON.stringify({ event: 'checkout_click', cartId }));
    showNotification('Sedang memproses pesanan...');
    
    // 2. Jalankan mutasi atau navigasi yang diperlukan
    processPayment(cartId);
  };

  return (
    <button onClick={handleCheckout}>
      Bayar Sekarang
    </button>
  );
}

Dengan menaruh logika di event handler, lu menghemat siklus render yang tidak perlu dan membuat alur kode lu jauh lebih gampang dibaca oleh engineer lain.


3. Fetching Data Tanpa AbortController dan Cleanup

Implementasi AbortController pada data fetching di React
AbortController membersihkan network request saat dependency berubah. · Florian Olivo (Unsplash)

Data fetching manual menggunakan useEffect dan fetch biasa rentan terhadap dua masalah kritis: Race Condition dan Memory Leak.

Bayangkan skenario di mana user berpindah tab atau mengetik filter pencarian dengan cepat. Request pertama (lambat) selesai setelah request kedua (cepat). Hasilnya, data usang menimpa data terbaru di layar.

Race Condition Saat Fetch Data Tanpa AbortControllerUserReact StateAPI Server1. Ketik 'React'2. Request A (Query: 'React')3. Ketik 'React 19'4. Request B (Query: 'React 19')5. Response B selesai (Data Baru)6. Response A selesai telat (Data Lama Menim…
Race Condition Saat Fetch Data Tanpa AbortController

Solusi Teknis: Gunakan AbortController

Jika lu terpaksa melakukan data fetching menggunakan native useEffect tanpa library pihak ketiga, selalu sediakan cleanup function menggunakan AbortController.

tsx
// ✅ BEST PRACTICE: Fetching dengan AbortController dan Cleanup Function
import { useState, useEffect } from 'react';

interface UserProfile {
  id: string;
  username: string;
  bio: string;
}

export function UserProfileViewer({ userId }: { userId: string }) {
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // 1. Inisialisasi controller pembatalan
    const controller = new AbortController();
    const { signal } = controller;

    async function fetchUserProfile() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`https://api.example.com/users/${userId}`, { signal });
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data: UserProfile = await response.json();
        setProfile(data);
      } catch (err: unknown) {
        // Abaikan error jika request dibatalkan secara sengaja oleh cleanup
        if (err instanceof Error && err.name === 'AbortError') {
          return;
        }
        setError(err instanceof Error ? err.message : 'Terjadi kesalahan');
      } finally {
        if (!signal.aborted) {
          setLoading(false);
        }
      }
    }

    fetchUserProfile();

    // 2. Cleanup function: batalkan request yang sedang berjalan saat userId berganti atau komponen unmount
    return () => {
      controller.abort();
    };
  }, [userId]);

  if (loading) return <p>Memuat profil...</p>;
  if (error) return <p className="text-red-500">{error}</p>;
  if (!profile) return null;

  return (
    <div>
      <h1>{profile.username}</h1>
      <p>{profile.bio}</p>
    </div>
  );
}

Penjelasan Baris per Baris:

  • Baris 17: const controller = new AbortController() membuat instance baru yang mengontrol siklus hidup request.
  • Baris 24: Properti { signal } diteruskan ke parameter fetch. Browser sekarang tahu request ini bisa dibatalkan sewaktu-waktu.
  • Baris 31-33: Saat controller.abort() dipanggil, fetch melempar error dengan name === 'AbortError'. Kita menangkapnya agar tidak menampilkan pesan error palsu ke UI.
  • Baris 42-44: Cleanup function dijalankan oleh React sebelum effect berikutnya dieksekusi atau saat komponen unmount. Ini memutus koneksi HTTP yang menggantung dan mencegah race condition.
Rekomendasi Senior: Untuk aplikasi skala menengah ke atas, hindari menulis boilerplate useEffect untuk fetching. Gunakan library server-state yang matang seperti TanStack Query (React Query) atau SWR, atau manfaatkan React Server Components (RSC) jika menggunakan Next.js / Remix.

4. Object & Function Literal di Dependency Array

Javascript mengevaluasi kesetaraan objek dan fungsi berdasarkan referensi memori (referential equality), bukan berdasarkan isinya. Mengirimkan objek literal atau fungsi inline ke dependency array useEffect adalah jalan tol menuju infinite re-render loop.

tsx
// ❌ ANTI-PATTERN: Objek baru dibuat di setiap render pass
import { useState, useEffect } from 'react';

export function DashboardBad({ organizationId }: { organizationId: string }) {
  const [data, setData] = useState<any>(null);

  // Objek options ini SELALU mendapat referensi memori baru di setiap render
  const fetchOptions = {
    orgId: organizationId,
    timestamp: Date.now(),
  };

  useEffect(() => {
    fetchMetrics(fetchOptions).then(res => setData(res));
  }, [fetchOptions]); // 💥 Infinite Loop: fetchOptions selalu dianggap berbeda!

  return <div>{/* Render Dashboard */}</div>;
}

Mengapa Terjadi Infinite Loop?

  1. Komponen me-render pertama kali.
  2. fetchOptions dialokasikan di alamat memori 0x001.
  3. useEffect berjalan, memanggil API, lalu mengeksekusi setData.
  4. setData memicu render ulang.
  5. Komponen me-render kedua kali. fetchOptions dibuat ulang di alamat memori 0x002.
  6. React membandingkan dependency: 0x001 !== 0x002 (berbeda!).
  7. useEffect jalan lagi -> panggil setData -> loop berulang tanpa henti hingga browser crash.

Solusi: Sederhanakan Dependency ke Tipe Primitif atau Pindahkan ke Dalam Effect

tsx
// ✅ BEST PRACTICE: Pindahkan objek/fungsi ke dalam effect atau gunakan primitive dependency
import { useState, useEffect } from 'react';

export function DashboardGood({ organizationId }: { organizationId: string }) {
  const [data, setData] = useState<any>(null);

  useEffect(() => {
    // Deklarasikan objek di DALAM effect jika hanya dipakai di sini
    const fetchOptions = {
      orgId: organizationId,
      timeout: 5000,
    };

    let isMounted = true;
    fetchMetrics(fetchOptions).then((res) => {
      if (isMounted) setData(res);
    });

    return () => {
      isMounted = false;
    };
  }, [organizationId]); // ✅ Aman: organizationId adalah string primitif

  return <div>{/* Render Dashboard */}</div>;
}

Jika lu membutuhkan fungsi callback yang di-pass dari parent, bungkus fungsi tersebut dengan useCallback di level parent, atau pertimbangkan untuk hanya mem-pass data primitif yang dibutuhkan fungsi tersebut.


5. Menyelaraskan State Antar Komponen Anak-Induk (Prop Drilling Reset)

Sering kali kita membuat form atau modal di komponen anak yang memiliki state lokal. Ketika user memilih item lain di parent, kita ingin me-reset state lokal di komponen anak tersebut. Pola yang sering salah kaprah adalah menggunakan useEffect untuk menimpa state lokal setiap kali props berubah.

tsx
// ❌ ANTI-PATTERN: Sinkronisasi props ke local state via useEffect
import { useState, useEffect } from 'react';

interface EditProfileProps {
  initialBio: string;
  userId: string;
}

export function EditProfileBad({ initialBio, userId }: EditProfileProps) {
  const [bio, setBio] = useState(initialBio);

  // Berbahaya: Setiap kali userId berubah, render ulang terjadi dua kali
  useEffect(() => {
    setBio(initialBio);
  }, [userId, initialBio]);

  return (
    <textarea 
      value={bio} 
      onChange={(e) => setBio(e.target.value)} 
    />
  );
}

Selain memicu render ganda, pola ini rentan memunculkan bug subtle: jika user sedang mengetik dan ada background re-render dari parent yang mengubah initialBio, ketikan user bisa mendadak hilang tertimpa nilai lama.

Solusi Elegan: Gunakan Atribut key untuk Mereset Komponen

React memiliki mekanisme bawaan untuk mereset seluruh state komponen secara otomatis: cukup berikan properti key yang unik pada komponen tersebut di level parent.

tsx
// ✅ BEST PRACTICE: Manfaatkan 'key' untuk reset state secara deklaratif

// 1. Komponen Anak tetap bersih dan tidak butuh useEffect sama sekali
interface EditProfileProps {
  initialBio: string;
}

export function EditProfileGood({ initialBio }: EditProfileProps) {
  // State otomatis terinisialisasi ulang dari nol ketika key di parent berubah
  const [bio, setBio] = useState(initialBio);

  return (
    <textarea 
      value={bio} 
      onChange={(e) => setBio(e.target.value)} 
    />
  );
}

// 2. Parent mengontrol lifecycle anak lewat 'key'
export function ProfilePage({ user }: { user: { id: string; bio: string } }) {
  return (
    <div>
      <h2>Edit Profil</h2>
      {/* Setiap kali user.id berganti, React me-mount ulang EditProfileGood */}
      <EditProfileGood key={user.id} initialBio={user.bio} />
    </div>
  );
}

Ketika nilai key berubah dari user-1 ke user-2, React akan menghancurkan (unmount) instance komponen lama beserta state-nya, lalu membuat instance baru dengan nilai initialBio yang baru. Tidak ada double render, tidak ada stale state, dan kode lu bersih tanpa sebaris pun useEffect.


Mental Model: Kapan Sebenarnya Kita Butuh useEffect?

Sebelum lu mengetik useEffect di editor, gunakan decision tree berikut sebagai panduan mental:

Mental Model Evaluasi Kebutuhan useEffectAda Kebutuhan Logika BaruTentukan skenarioBisa Dihitung dari State/Props?Derived computationHitung Langsung saat RenderInline / useMemoTerjadi Karena Interaksi User?Event handlingTaruh di Event HandleronClick / onChange / onSubmitSinkronisasi ke Sistem Eksternal?DOM, Socket, Timer, LocalStorageEvaluasiYaYaTidakTidakGunakan useEffect + Cleanup
Mental Model Evaluasi Kebutuhan useEffect

Tanyakan 3 pertanyaan ini secara berurutan:

  1. Apakah data ini bisa dihitung dari props atau state yang sudah ada?

Jika YA -> Hitung langsung di badan komponen. Gunakan useMemo hanya jika perhitungannya terbukti berat secara benchmark.

  1. Apakah aksi ini terjadi akibat interaksi spesifik user (klik, submit, scroll)?

Jika YA -> Pindahkan ke event handler terkait (onClick, onSubmit).

  1. Apakah kode ini bertujuan menyelaraskan komponen dengan sistem di luar React (DOM browser, WebSockets, Subscription, LocalStorage)?

Jika YA -> Ini adalah satu-satunya skenario valid untuk menggunakan `useEffect`. Jangan lupa sertakan cleanup function jika sistem tersebut membuka koneksi atau event listener.


Checklist Action Items untuk Tim Engineering

Sebelum merge pull request berikutnya ke branch utama, lakukan audit cepat terhadap komponen React lu:

  1. Hapus State Berlebih: Cari kombinasi const [val, setVal] = useState() yang selalu diperbarui di dalam useEffect. Ubah menjadi variabel biasa di render body.
  2. Audit Dependency Array: Pastikan tidak ada fungsi atau objek anonim yang dibuat secara inline di dalam dependency array useEffect.
  3. Validasi Cleanup Function: Pastikan semua addEventListener, setInterval, setTimeout, dan network call di dalam useEffect memiliki fungsi pembatalan (cleanup return).
  4. Pasang ESLint Plugin: Aktifkan rule eslint-plugin-react-hooks dengan konfigurasi ketat (exhaustive-deps: error) untuk mencegah stale closure dan dependency yang terlewat.
  5. Gunakan Profiler: Buka Chrome DevTools atau React DevTools Profiler, rekam interaksi user, dan verifikasi bahwa satu klik tidak memicu lebih dari satu render pass yang tidak perlu.

Menulis kode React yang cepat bukan soal menghafal trik optimasi rumit, melainkan soal disiplin membiarkan React bekerja sesuai alur deklaratifnya tanpa dicekik oleh rantai useEffect yang tak berujung. Happy refactoring!

Butuh penyesuaian khusus untuk project Anda?

Jika situasi operasional atau arsitektur sistem bisnis Anda membutuhkan solusi kustom, diskusikan langsung bersama tim engineer kami. Anda juga bisa melihat rincian layanan vour.dev atau menghitung estimasi biaya project lebih dulu.

Mulai Project