useEffect Bağımlılık Listesi Yanlış Kurulunca Neler Olur?
useEffect bağımlılık dizisi yanlış kurulduğunda bileşen gereksiz yere yeniden çalışır, sonsuz döngüye girer ya da bayat veriyle kalır; hangi hata hangi davranışa yol açar ve nasıl onarılır?
Bir bileşen sayfaya girdiği anda API isteği atıyor, sonra bir daha hiç durmadan atmaya devam ediyor. Network sekmesinde aynı endpoint saniyede birkaç kez tekrarlanıyor, tarayıcı sekmesi kısa sürede kilitleniyor. Kod incelendiğinde suçlu neredeyse her zaman aynı yerde bulunuyor: useEffect'in ikinci parametresi, yani bağımlılık listesi. Ya eksik bir değer var, ya fazladan bir referans var, ya da liste tamamen unutulmuş.
useEffect'in çalışma mantığı basittir: React her render'dan sonra bağımlılık listesindeki değerleri bir önceki render ile karşılaştırır, herhangi biri değiştiyse effect fonksiyonunu yeniden çalıştırır. Sorun bu karşılaştırmanın kendisinde değil, listeye neyin girip neyin girmediğinde ortaya çıkar. Yanlış kurulmuş bir bağımlılık listesi üç farklı şekilde hasar verir: sonsuz döngü üretir, gereksiz network isteği tetikler veya effect içinde eski bir state değerine sürekli erişilmesine (stale closure) yol açar.
Bu üç sorunun her biri farklı bir belirtiyle ortaya çıkar ve farklı bir tanı yöntemi gerektirir. Aşağıda her birinin nasıl tespit edildiği, kök nedeninin ne olduğu ve nasıl düzeltildiği sırayla ele alınıyor.
Bağımlılık listesi eksik kalınca sonsuz döngü nasıl başlar?
En sık karşılaşılan sonsuz döngü deseni, effect içinde state güncellenirken o state'in kendisinin bağımlılık listesinde referans tipiyle yer almasıdır. Aşağıdaki örnek klasik bir tuzaktır:
function ProductList() {
const [filters, setFilters] = useState({ category: 'all' });
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts(filters).then(setProducts);
// filters her render'da yeni bir nesne referansı ise
// bu effect her commit'ten sonra tekrar tetiklenir
}, [filters]);
return <ProductGrid items={products} />;
}
Burada filters bir nesne. Eğer bu nesne her render'da { category: 'all' } gibi satır içinde yeniden oluşturuluyorsa, referans her seferinde farklıdır ve Object.is karşılaştırması her zaman "değişti" der. Effect çalışır, setProducts çağrılır, bileşen yeniden render olur, filters yine yeni bir referansla üretilir, effect yine çalışır. Döngü, veri gerçekten değişmediği halde durmaz.
Tespit yöntemi nettir: Network sekmesinde aynı isteğin art arda, kullanıcı hiçbir etkileşimde bulunmadan tekrarlandığını görürseniz, önce bağımlılık listesindeki nesne veya dizi tipindeki değerlere bakın. Çözüm, referansı kararlı hale getirmektir; ya filters state'ini yalnızca gerçek bir kullanıcı etkileşiminde güncelleyin, ya da nesneyi useMemo ile sarıp içeriği değişmediği sürece aynı referansı koruyun. İkinci bir yaygın sonsuz döngü kaynağı, effect içinde günceli tetikleyen bir state'in doğrudan bağımlılık listesine yazılmasıdır; bu durumda genelde çözüm o state'i işlevsel güncelleme (setState(prev => ...)) ile bağımlılıktan tamamen çıkarmaktır.
Gereksiz fetch: aynı veri neden tekrar tekrar çekilir?
Sonsuz döngüden daha sinsi bir sorun, uygulamanın çökmemesi ama aynı veriyi gereksiz yere defalarca çekmesidir. Bu genelde bağımlılık listesine ihtiyaç duyulmayan bir değerin eklenmesinden kaynaklanır:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const theme = useContext(ThemeContext);
useEffect(() => {
fetchUser(userId).then(setUser);
// theme burada hiç kullanılmıyor ama bağımlılık listesinde
}, [userId, theme]);
return <ProfileCard user={user} theme={theme} />;
}
Effect'in içinde theme değişkeni hiç kullanılmıyor, ama listeye eklenmiş. Kullanıcı tema değiştirdiğinde bu effect gereksiz yere yeniden çalışır ve aynı kullanıcı verisi tekrar sunucudan istenir. Bu tür fazlalıklar genellikle "her ihtimale karşı ekleyeyim" refleksiyle veya ESLint uyarısını susturmak için deneme yanılmayla listeye eklenen değerlerden doğar.
Tersi durum da aynı derecede yaygındır: effect içinde kullanılan bir değer listeye eklenmemiş, ama görünüşte çalışıyor gibi durur çünkü ilk render'da doğru veri gelmiştir. Bu senaryoda gereksiz fetch değil, güncel olmayan veri sorunu ortaya çıkar; kullanıcı ID'si değiştiğinde profil ekranı hâlâ eski kullanıcıyı gösterir. Her iki durumda da tanı aracı aynıdır: effect'in gövdesinde hangi dış değişkenlerin okunduğunu satır satır çıkarın ve bunu bağımlılık listesiyle birebir karşılaştırın. Listede fazlalık varsa gereksiz tetikleme, eksiklik varsa bayat veri riski vardır.
Stale closure: effect içinde eski state'e neden takılırsınız?
Stale closure, JavaScript'in closure mekanizmasının doğal bir sonucudur; effect fonksiyonu tanımlandığı render anındaki değişkenleri hapseder. Bağımlılık listesi eksik bırakılırsa, effect ilk çalıştığı andaki değerlerle sonsuza kadar çalışmaya devam eder:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// count burada her zaman 0 kalır; interval yalnızca
// ilk render'daki closure'ı hapsetmiştir
console.log('Güncel sayaç:', count);
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // count eksik
return <p>{count}</p>;
}
Ekranda sayaç görsel olarak artıyor gibi görünebilir çünkü setCount(count + 1) her saniye çağrılıyor, ama count değişkeni her zaman 0 değerine sabitlenmiştir; bu yüzden değer aslında 0'dan 1'e çıkıp orada kalır, artmaz. Bu tür hatalar özellikle interval, event listener veya WebSocket callback'lerinde ortaya çıkar; çünkü bunlar uzun ömürlüdür ve tanımlandıkları andaki closure'ı taşırlar.
İki farklı çözüm yolu vardır. Birincisi, count'u bağımlılık listesine eklemek; bu doğru değeri okumasını sağlar ama her değişimde interval'i sıfırdan kurar, ki bu bazı senaryolarda istenmeyen bir yan etkidir. İkincisi, işlevsel güncelleme kullanmaktır:
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1); // her zaman güncel prev'i alır
}, 1000);
return () => clearInterval(id);
}, []); // count'a artık ihtiyaç yok
İşlevsel güncelleme, React'in bir sonraki state değerini hesaplarken güncel değeri kendisinin sağlaması sayesinde closure'ın bayat kalmasını önler. Bu desen, interval'i her seferinde yeniden kurmadan doğru değere erişmeniz gerektiğinde tercih edilmelidir. Ana iş parçacığını gereksiz yere meşgul eden tekrarlayan timer'lar da genelde bu köke iner: closure yanlış kurulduğu için her tetiklemede fazladan iş yapılır.
exhaustive-deps kuralını kapatmak neden geçici çözüm değildir?
ESLint'in react-hooks/exhaustive-deps kuralı, effect içinde kullanılan her değişkenin bağımlılık listesinde bulunmasını zorunlu kılar. Bu uyarı geldiğinde en hızlı çözüm gibi görünen yol, satırın üstüne // eslint-disable-next-line eklemek veya kuralı proje genelinde kapatmaktır. Bu, sorunu çözmez, yalnızca görünmez kılar.
Kural genelde iki nedenle "yanlış" uyarı verir gibi görünür. Birincisi, gerçekten stabil olması gereken bir fonksiyon veya nesne her render'da yeniden oluştuğu için listeye eklenmek istenmez; bu durumda doğru çözüm kuralı susturmak değil, o değeri useCallback veya useMemo ile sabitlemektir. İkincisi, effect'in yalnızca bileşen ilk yüklendiğinde çalışması isteniyordur ve geliştirici bunu boş dizi ile zorlar; ama içeride kullanılan bir prop veya state varsa bu, o değerin güncellenmesini effect'in görmezden gelmesine yol açar.
Kuralı kapatmak yerine sorulması gereken soru şudur: bu effect'in gerçekten yalnızca bir kez mi çalışması gerekiyor, yoksa belirli bir değer değiştiğinde de mi tekrar çalışması gerekiyor? Cevap "yalnızca bir kez" ise, bu effect'in ihtiyaç duyduğu değerlerin bileşenin yaşam döngüsü boyunca sabit kalması garanti altına alınmalıdır; genelde bu, ilgili değeri prop olarak değil sabit bir referans olarak taşımak veya effect'i tamamen farklı bir tetikleyiciye bağlamak anlamına gelir. Kural, kod tabanında büyüdükçe fark edilmesi zorlaşan stale closure ve eksik senkronizasyon hatalarını erken yakalamak için vardır; kapatıldığında bu hatalar üretime kadar sessiz kalır.
Temizlik fonksiyonu eksikliği hangi sorunları büyütür?
Bağımlılık listesi doğru kurulsa bile, effect'in geri döndürdüğü temizlik (cleanup) fonksiyonu eksikse sorun farklı bir şekilde geri döner. Bir bileşen unmount olduğunda veya bağımlılık değiştiği için effect yeniden çalıştığında, önceki çalıştırmanın abonelikleri, timer'ları veya event listener'ları temizlenmezse birikirler:
function SearchBox({ query }) {
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data));
return () => controller.abort();
// query her değiştiğinde önceki istek iptal edilir
}, [query]);
}
Temizlik fonksiyonu olmadan, kullanıcı hızlıca yazarken her tuş vuruşunda yeni bir istek başlar ve önceki isteklerin hiçbiri iptal edilmez. Ağ gecikmesi tutarsız olduğunda yanıtlar sırasız döner; önceki bir sorgunun cevabı, daha sonra gönderilen bir sorgunun cevabından geç gelip ekranı yanlış sonuçla günceller. Bu, bundle boyutu gibi statik bir metrikle görünmeyen, yalnızca gerçek kullanım sırasında ortaya çıkan bir performans ve doğruluk sorunudur.
Aynı ihmal event listener'larda daha kalıcı bir hasar bırakır. Bir bileşen her render'da window.addEventListener ekleyip temizlik fonksiyonunda kaldırmıyorsa, bileşen defalarca mount/unmount olduğunda listener sayısı katlanarak artar; her scroll veya resize olayında aynı işlem onlarca kez tekrar çalışır. Bu tür sızıntılar React DevTools Profiler'da doğrudan görünmez, ama etkileşim gecikmesi olarak zamanla kendini hissettirir. Kontrol yöntemi basittir: effect içinde bir şey abone oluyorsa, kaydediyorsa veya başlatıyorsa, temizlik fonksiyonunda tam simetrik bir iptal işlemi olmalıdır.
Doğru bağımlılık listesini kurmak için pratik kontrol sırası
Bir effect yazıldığında veya gözden geçirildiğinde şu sıra takip edilebilir. Önce effect'in gövdesinde hangi dış değişkenlerin okunduğu çıkarılır; bu liste ESLint kuralının önerdiği listeyle karşılaştırılır. Uyuşmazlık varsa, kuralı susturmak yerine neden uyuşmadığı sorgulanır: değer gerçekten kararsız mı, yoksa gereksiz mi?
Sonra, listedeki her nesne ve dizi tipindeki değerin render'lar arasında referans olarak kararlı olup olmadığı kontrol edilir. Kararsızsa, ya kaynağında useMemo/useCallback ile sabitlenir ya da effect'in ihtiyaç duyduğu yalnızca primitive alanlar (örneğin nesnenin tek bir alanı) bağımlılık listesine alınır; bu, React.memo kararında karşılaşılan referans kararlılığı sorunuyla aynı köke dayanır. Son olarak, effect içinde herhangi bir abonelik, timer veya dinleyici başlatılıyorsa, temizlik fonksiyonunun bunun tam karşılığını iptal ettiği doğrulanır.
Bu üç kontrol; bağımlılık listesinin eksiksizliği, referans kararlılığı ve temizlik simetrisi, çoğu useEffect hatasının kök nedenini kapsar. Karmaşık effect'lerde dördüncü bir kontrol daha eklenebilir: effect birden fazla işi aynı anda yapıyorsa (örneğin hem veri çekiyor hem DOM ölçümü yapıyor hem de bir abonelik kuruyorsa), bunları ayrı effect'lere bölmek her birinin bağımlılık listesini küçültür ve hatanın hangi işten kaynaklandığını izole etmeyi kolaylaştırır. Büyük bileşenlerde render maliyetini düşürmeye çalışırken bu ayrıştırma, hem okunabilirliği hem de her effect'in davranışını tek başına test edebilme imkanını artırır.
useEffect'in kendisi karmaşık bir API değildir; karmaşıklık, listeye neyin girip neyin girmediğine dair kararların proje büyüdükçe tutarsızlaşmasından doğar. Her effect yazıldığında "bu değer değiştiğinde bu kod gerçekten tekrar çalışmalı mı" sorusuna açık bir cevap verilmesi, sonsuz döngüyü de gereksiz fetch'i de stale closure'ı da büyük ölçüde önler. Sorun ortaya çıktığında ilk bakılacak yer neredeyse her zaman aynıdır: effect'in gövdesi ile bağımlılık listesi arasındaki uyuşmazlık.