Mert Özen Her Satırda Daha İleri
Frontend React — #06

useEffect'i Doğru Kullanmak: Synchronization, Cleanup, Dependency Array ve "Effect'e İhtiyacın Olmayabilir"

Mert Özen 15 Kas 2026 13 dk 3 görüntülenme
useEffect'i Doğru Kullanmak: Synchronization, Cleanup, Dependency Array ve "Effect'e İhtiyacın Olmayabilir"

useEffect bir lifecycle hook'u değil, component'i dış bir sistemle senkronize etme aracıdır: her setup'ın bir cleanup'ı olur, dependency array effect'in okuduğu reaktif değerlerin listesidir. Bookstore kataloğunu API aramasına bağlayıp debounce ve AbortController ile StrictMode'da bile tek istek gittiğini, cleanup'sız sürümde geciken yanıtın listeyi ezdiğini, effect ile türetilen sayının render'ı ikiye katladığını ve useEffectEvent'in gereksiz isteği önlediğini gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler React 19.3, TypeScript 7.0, Vite 8.3, Oxlint 1.93 ve Node.js 24 ile denenmiştir; API çağrıları Bookstore.Api'nin (ASP.NET Core 10) yerel bir kopyasına Vite dev proxy'si üzerinden yapılmış, network ve konsol kayıtları Playwright 1.65 ile headless Chromium'da toplanmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React kataloğu artık kitapları sabit bir dosyadan değil, Bookstore API'den alıyor. Arama kutusuna yazılan her harf query state'ini değiştiriyor ve bir effect GET /api/books?search=... isteğini atıyor. Effect 300 ms bekleyip öyle istek atıyor (debounce) ve her yeni harfte bir önceki bekleyişi ve isteği iptal ediyor.

Uygulama npm run dev ile, yani StrictMode'un effect'leri bilerek iki kez çalıştırdığı modda açılıyor. Sayfa ilk yüklendiğinde API'ye kaç istek gider: bir mi, iki mi? Kullanıcı önce "Jane" yazıp yarım saniye duraklıyor, sonra " Eyre" ekliyor; "Jane" isteğinin yanıtı ağdaki bir gecikme yüzünden "Jane Eyre" yanıtından sonra gelirse ekranda hangi kitaplar kalır? Bu iki sorunun cevabı effect'in neden var olduğunu ve cleanup'ın neden zorunlu olduğunu anlatıyor.

Effect Ne İçin Var? Lifecycle Değil, Senkronizasyon

react.dev useEffect'i bir component'i bir dış sistemle senkronize etmeye yarayan hook olarak tanımlıyor ve ekliyor: bir dış sistemle senkronize olmaya çalışmıyorsan muhtemelen effect'e ihtiyacın yok. Dış sistem React'in kontrol etmediği her şey: network, tarayıcı API'leri, bir WebSocket bağlantısı, bir harita kütüphanesi, document.title.

Angular'dan gelen biri effect'i bir lifecycle hook'u gibi okumaya meyilli: "ngOnInit'te veriyi çekiyordum, demek ki useEffect(..., []) yazacağım; ngOnDestroy'da aboneliği kapatıyordum, demek ki cleanup". Bu okuma ilk bakışta çalışıyor ama yanlış soruyu soruyor. Effect "component ne zaman doğdu, ne zaman öldü" sorusuyla değil, "şu anki props ve state ile dış dünyanın hangi durumda olması gerekiyor" sorusuyla ilgileniyor. Katalogda bu soru şöyle: "query şu an 'Dune' ise ekrandaki liste, API'nin 'Dune' için döndürdüğü liste olmalı." Query değiştiğinde eski senkronizasyon bitiyor (cleanup), yenisi başlıyor (setup). Mount ve unmount bu döngünün yalnızca ilk ve son adımı.

Angular'ın kendi effect()'i de benzer bir araç; signal'lara bağlı yan etkiler için. Ama Angular'da veri çekmenin önerilen yolu effect değil resource ve httpResource, React'te de üretimde çoğunlukla TanStack Query gibi bir kütüphane. Bu yazıda effect ile veri çekiyoruz, çünkü effect'in mekaniğini en iyi gösteren örnek bu; sekizinci yazıda aynı ekranı TanStack Query'ye taşıyacağız.

Katalog Artık API'den: Effect, Cleanup ve Dependency Array

CatalogPage'in yeni hâli:

import { useEffect, useEffectEvent, useState } from 'react';
import { BookDetailsPanel } from '@/features/catalog/BookDetailsPanel';
import { BookList } from '@/features/catalog/BookList';
import { SearchBox } from '@/features/catalog/SearchBox';
import type { BookSummary, PagedResult } from '@/features/catalog/types';

type SortBy = 'title' | 'price';

type SearchResult =
  | { status: 'success'; query: string; books: BookSummary[]; total: number }
  | { status: 'error'; query: string; message: string };

type CatalogPageProps = {
  quantities: Record<string, number>;
  cartItemCount: number;
  onAdd: (book: BookSummary, amount: number) => void;
};

const debounceMs = 300;

export function CatalogPage({ quantities, cartItemCount, onAdd }: CatalogPageProps) {
  const [query, setQuery] = useState('');
  const [sortBy, setSortBy] = useState<SortBy>('title');
  const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);
  const [result, setResult] = useState<SearchResult | null>(null);

  // Reaktif değil: effect'i yeniden çalıştırmadan cartItemCount'un son değerini okur
  const onSearchFinished = useEffectEvent((searched: string, total: number) => {
    console.info(`[search] "${searched}" -> ${total} book(s), cart has ${cartItemCount} item(s)`);
  });

  // Sonuç listesini o anki query için API ile senkronize eder
  useEffect(() => {
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      try {
        const url = `/api/books?search=${encodeURIComponent(query.trim())}&pageSize=50`;
        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const page = (await response.json()) as PagedResult<BookSummary>;
        setResult({ status: 'success', query, books: page.items, total: page.total });
        onSearchFinished(query, page.total);
      } catch (error) {
        if (controller.signal.aborted) {
          return; // cleanup çalıştı: bu yanıt eski bir query'ye ait
        }
        setResult({ status: 'error', query, message: error instanceof Error ? error.message : String(error) });
      }
    }, debounceMs);

    // Cleanup: bir sonraki effect'ten önce ve unmount'ta çalışır
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  // Render sırasında türetilir: ek state yok, ek effect yok
  const isLoading = result === null || result.query !== query;
  const books = result?.status === 'success'
    ? result.books.toSorted((a, b) => (sortBy === 'price' ? a.price - b.price : a.title.localeCompare(b.title)))
    : [];
  const selectedBook = books.find((b) => b.isbn === selectedIsbn) ?? null;

  return (
    <>
      <h1>Catalog</h1>
      <div className="toolbar">
        <SearchBox value={query} onChange={setQuery} />
        <label>
          Sort by{' '}
          <select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortBy)}>
            <option value="title">Title</option>
            <option value="price">Price</option>
          </select>
        </label>
      </div>
      <p className="muted" role="status">
        {isLoading ? 'Loading…' : result.status === 'error' ? `Could not load books (${result.message})` : `${result.total} book(s) found`}
      </p>
      {(books.length > 0 || !isLoading) && (
        <BookList books={books} quantities={quantities} onAdd={onAdd} onSelect={setSelectedIsbn} />
      )}
      {selectedBook && <BookDetailsPanel key={selectedBook.isbn} book={selectedBook} />}
    </>
  );
}

Effect fonksiyonu (setup) bir timer kuruyor; 300 ms sonra istek atılıyor ve yanıt setResult ile state'e yazılıyor. Effect'in döndürdüğü fonksiyon cleanup: timer'ı iptal ediyor ve AbortController ile uçuşta olan isteği kesiyor. İkinci argüman olan dependency array [query], React'e "bu effect query'ye bağlı" diyor. React her render'dan sonra array'deki değerleri bir önceki render'ınkilerle Object.is ile karşılaştırıyor; biri değiştiyse önce eski effect'in cleanup'ını, sonra yeni setup'ı çalıştırıyor. Array boşsa ([]) effect yalnız mount'ta kurulur ve unmount'ta temizlenir; array hiç verilmezse her render'dan sonra çalışır.

Dependency array senin tercihin değil, effect'in içinde kullandığın reaktif değerlerin listesi. Reaktif değer, render'dan render'a değişebilen her şey: props, state ve bunlardan component içinde hesaplanan değerler. Effect query'yi okuduğu için query array'de olmak zorunda; eksik bırakırsan effect eski bir query'yi okumaya devam eder. İkinci yazıda eklediğimiz exhaustive-deps kuralı bunu denetliyor ve bu dosya için uyarı vermiyor.

Yükleme durumu için ayrı bir isLoading state'i yok. Sonuç hangi query için geldiyse onunla birlikte saklanıyor; result.query !== query ise ekrandaki liste eski, yani yükleniyoruz. Bu, bir önceki yazıdaki "hesaplanabileni state'e koyma" kuralının effect'li hâli ve effect'in içinde setIsLoading(true) çağırmanın getireceği ek render'ı da ortadan kaldırıyor. App tarafında tek değişiklik, kataloğa sepetteki ürün sayısını vermek:

          <CatalogPage quantities={quantities} cartItemCount={cartItemCount(lines)} onAdd={handleAdd} />

Geliştirme modunda sayfayı açıp "Dune" yazdık, Dune'u sepete ekledik, sonra kutuya "Austen" yazdık. Soldaki sayı sayfanın açılışından itibaren geçen milisaniye:

  373 ms request:  ?search=&pageSize=50
  401 ms console: [search] "" -> 20 book(s), cart has 0 item(s)
  860 ms status:   20 book(s) found
 1405 ms request:  ?search=Dune&pageSize=50
 1413 ms console: [search] "Dune" -> 1 book(s), cart has 0 item(s)
 1451 ms status:   1 book(s) found | Dune
 2311 ms request:  ?search=Austen&pageSize=50
 2318 ms console: [search] "Austen" -> 2 book(s), cart has 1 item(s)
 2794 ms status:   2 book(s) found | Emma, Pride and Prejudice

"Önce Tahmin Et"teki ilk sorunun cevabı burada: StrictMode'a rağmen ilk yüklemede tek istek gitti. "Dune" yazılırken dört harf dört kez effect'i yeniden kurdu ama yalnız son harften 300 ms sonra istek gitti; aradaki timer'ları cleanup iptal etti.

06-effect-lifecycle-tr.png

StrictMode'da Çift Çalışma Ne Gösteriyor?

StrictMode geliştirmede her effect'i mount'tan hemen sonra bir kez temizleyip yeniden kuruyor. Bizim effect'te ilk setup'ın timer'ı cleanup tarafından iptal edildiği için istek hiç gitmedi. Debounce'u kaldırıp isteği effect'in içinde hemen başlatan bir sürümle aynı sayfayı açtık:

request: ?search=&pageSize=50
request: ?search=&pageSize=50
failed:  ?search=&pageSize=50 net::ERR_ABORTED
console: [search] "" -> 20 book(s), cart has 0 item(s)
status: 20 book(s) found

İki istek gitti; ilki cleanup'taki abort() ile kesildi (net::ERR_ABORTED), ekrana ve konsola yalnız ikincisinin sonucu düştü. Production'da yalnız bir istek gider. Burada StrictMode bir hata göstermiyor, tersine cleanup'ın doğru çalıştığını kanıtlıyor. Cleanup olmasaydı iki yanıt da setResult çağıracaktı. Geliştirmede iki istek görmek seni rahatsız ediyorsa çözüm StrictMode'u kapatmak değil; veri çekmeyi cache'li bir kütüphaneye bırakmak, ki o da sekizinci yazının konusu.

Race Condition: Geç Gelen Eski Yanıt

Effect ile veri çekmenin klasik hatası, yanıtların istek sırasıyla gelmeyebilmesi. Cleanup'ı olmayan bir sürüm yazdık:

  // YANLIŞ: cleanup yok, her yanıt geldiği sırayla uygulanıyor
  useEffect(() => {
    fetch(`/api/books?search=${encodeURIComponent(query.trim())}&pageSize=50`)
      .then((response) => response.json() as Promise<PagedResult<BookSummary>>)
      .then((page) => setResult({ status: 'success', query, books: page.items, total: page.total }));
  }, [query]);

Playwright ile search=Jane isteğini 1,5 saniye geciktirdik, diğer istekleri olduğu gibi bıraktık. Kutuya "Jane" yazıp 400 ms sonra "Jane Eyre" yaptık ve 2,5 saniye bekledik:

input: Jane Eyre | status: Loading… | books: Emma, Jane Eyre, Pride and Prejudice

Kutuda "Jane Eyre" yazıyor ama listede "Jane" aramasının üç kitabı var: Jane Eyre yanıtı önce geldi ve ekrana yazıldı, ardından gecikmiş Jane yanıtı geldi ve onu ezdi. Durum satırının "Loading…" demesi tesadüf değil: result.query artık "Jane", kutudaki değer "Jane Eyre"; hesapladığımız yükleme durumu tutarsızlığı yakaladı ama listeyi düzeltemedi. Gerçek bir ağda bu durum yavaş bir mobil bağlantıda, yük altındaki bir API'de ya da sunucuda cache'lenmiş ve cache'lenmemiş sorguların karıştığı her yerde oluşur.

İlk çözüm, react.dev'in de gösterdiği ignore flag'i: cleanup çalıştığında o effect'in yanıtı artık önemsiz.

  // Çözüm 1: cleanup'ın set ettiği bir "ignore" flag'i
  useEffect(() => {
    let ignore = false;
    fetch(`/api/books?search=${encodeURIComponent(query.trim())}&pageSize=50`)
      .then((response) => response.json() as Promise<PagedResult<BookSummary>>)
      .then((page) => {
        if (!ignore) {
          setResult({ status: 'success', query, books: page.items, total: page.total });
        }
      });
    return () => {
      ignore = true;
    };
  }, [query]);
input: Jane Eyre | status: 1 book(s) found | books: Jane Eyre

ignore eski yanıtı yok sayıyor ama isteği iptal etmiyor; yanıt yine geliyor, yine indiriliyor, yalnız state'e yazılmıyor. Asıl sürümümüzdeki AbortController isteği tarayıcı seviyesinde kesiyor. Aynı gecikmeli senaryoda network kaydı:

request: ?search=&pageSize=50
request: ?search=Jane&pageSize=50
failed:  ?search=Jane&pageSize=50 net::ERR_ABORTED
request: ?search=Jane Eyre&pageSize=50
input: Jane Eyre | status: 1 book(s) found | books: Jane Eyre

"Jane" isteği yanıt gelmeden kesildi; fetch bir AbortError fırlattı ve catch bloğundaki controller.signal.aborted kontrolü onu hata olarak göstermeden yuttu. Bu kontrol önemli: abort'u bir hata gibi ekrana yazarsan kullanıcı her harfte bir anlık "Could not load books" görür. Angular tarafında switchMap ya da httpResource bu iptali senin yerine yapıyordu; RxJS serisinde ve "Angular 20: Stable Signal API'leri, httpResource ve Yeni Dosya Adlandırma" yazısında anlattığımız davranış bu.

Effect'e İhtiyacın Olmayabilir

react.dev'in "You Might Not Need an Effect" sayfası effect'in en çok yanlış kullanıldığı durumları sıralıyor. Bookstore'da üçünü daha önce farkında olmadan doğru çözdük; burada yanlış hâllerini ölçüyoruz.

Türetilmiş veri için effect. "Kaç kitap bulundu" sayısını bir state'e koyup effect ile güncelleyen sürüm:

  // YANLIŞ: türetilmiş bir değeri effect ile state'e kopyalamak
  const [visibleCount, setVisibleCount] = useState(0);
  useEffect(() => {
    setVisibleCount(books.length);
  }, [books]);

Oxlint iki ayrı uyarı veriyor:

src/features/catalog/CatalogPage.tsx:69:5: warning react(set-state-in-effect): Calling setState synchronously within an effect can trigger cascading renders
src/features/catalog/CatalogPage.tsx:70:7: warning react-hooks(exhaustive-deps): React hook useEffect depends on `books`, which changes every render

CatalogPage'in render sayısını iki sürümde production build'de saydık. Önce hesaplanan değerle, sonra effect'li sürümle:

renders after first load: 2 | status: 20 book(s) found
renders for typing "Dune": 5 | status: 1 book(s) found
renders after first load: 4 | status: 20 book(s) found
renders for typing "Dune": 11 | status: 1 book(s) found

Ekran aynı ama iş iki katından fazla: her render'da yeni bir books array'i oluştuğu için effect her render'dan sonra çalışıyor ve aynı sayıyı tekrar set ediyor. Bu sefer React aynı değeri görüp durdu; değer her seferinde farklı olsaydı döngü sonsuz olurdu. Kural: render sırasında hesaplanabilen bir şey için effect yazma.

Event'e ait mantık için effect. "Sepete eklenince bildirim göster" ya da "sipariş verilince POST at" gibi işler kullanıcının yaptığı belirli bir eyleme bağlı; bunlar event handler'a yazılır. Bir state değişikliğini izleyip effect'te POST atmak, aynı state başka bir sebeple değiştiğinde (sayfa yenilendi, geri tuşu, StrictMode) isteğin tekrar gitmesine yol açar.

Prop değişince state'i sıfırlamak için effect. Bunu bir önceki yazıda key ile yaptık; useEffect(() => setGiftNote(''), [book.isbn]) önce eski notla bir render yapar, sonra sıfırlar ve ekranda bir anlık yanlış değer gösterir.

useEffectEvent: Reaktif Olmayan Mantığı Ayırmak

Arama bittiğinde konsola bir analitik satırı yazıyoruz ve bu satırda sepetteki ürün sayısı da var. Effect'in içinde doğrudan cartItemCount'u okursak lint onu dependency array'e eklememizi istiyor. Eklersek ne olduğunu gördük:

request: ?search=&pageSize=50
console: [search] "" -> 20 book(s), cart has 0 item(s)
--- add Dune to the cart
request: ?search=&pageSize=50
console: [search] "" -> 20 book(s), cart has 1 item(s)

Sepete Dune eklemek aramayı yeniden çalıştırdı ve API'ye gereksiz bir istek gitti. Sepet sayısı effect'in senkronize ettiği şeyin (arama sonucu) parçası değil; yalnız "arama bitti" olayı gerçekleştiğinde o anki değeri okumak istiyoruz. React 19.2 ile stable olan useEffectEvent bu ayrımı yapıyor: içine yazılan fonksiyon her zaman son props ve state'i görüyor ama effect'in dependency'si sayılmıyor. Yukarıdaki asıl kodda onSearchFinished böyle tanımlı ve aynı senaryo:

request: ?search=&pageSize=50
console: [search] "" -> 20 book(s), cart has 0 item(s)
--- add Dune to the cart

Sepete ekleme yeni bir istek doğurmadı. İlk koşudaki "Austen" satırında "cart has 1 item(s)" yazması da fonksiyonun güncel değeri gördüğünü gösteriyor. react.dev bir uyarı da ekliyor: useEffectEvent dependency listesini kısaltmak için bir kaçış yolu değil; yalnız gerçekten effect'ten tetiklenen bir "olay" mantığı için kullanılmalı. Oxlint'in exhaustive-deps kuralı onSearchFinished'ı dependency olarak istemedi; React ekibinin ESLint plugin'inde de effect event'leri array'e yazılmamalı.

useLayoutEffect ise aynı API'ye sahip ama tarayıcı ekranı boyamadan önce çalışıyor. Bir tooltip'in boyutunu ölçüp konumunu ayarlamak gibi, kullanıcının yanlış ilk hâli bir an bile görmemesi gereken DOM (tarayıcının sayfayı tuttuğu node ağacı) ölçümleri için var. Boyamayı bloke ettiği için varsayılan seçim her zaman useEffect.

Üretimde Dikkat

Effect ile veri çekmek çalışıyor ama cache yok: kullanıcı "Dune" aramasına geri döndüğünde aynı istek tekrar gidiyor. Sayfalar arasında gezince sonuçlar kayboluyor, aynı veriyi iki component isterse iki istek gidiyor, iç içe component'ler sırayla yükleniyorsa istekler waterfall oluşturuyor. Bunların hepsi TanStack Query gibi bir kütüphanenin çözdüğü sorunlar. Effect'li sürümü, ne kazandığımızı ölçebilmek için bir referans olarak saklıyoruz.

Debounce süresi bir ürün kararı: çok kısaysa her harf istek doğurur, çok uzunsa arama ağır hissettirir. Kullanıcı Enter'a bastığında beklemeden aramak, kutu boşaltıldığında anında tüm listeyi göstermek gibi istisnaları da düşün. Sunucu tarafında arama sorgusunun bir üst sınırı (sayfa boyutu) olmalı; Bookstore API pageSize'ı 50 ile sınırlıyor.

Abort'u hata ayıklamada ayırt et: log'larda ve hata takip aracında AbortError'ları hata olarak raporlama. Aksi hâlde her hızlı yazan kullanıcı, alarm üreten bir "hata" kaynağına dönüşür.

Sık Düşülen Tuzaklar

Eksik dependency: effect'in okuduğu bir prop ya da state'i array'e yazmamak, effect'in eski değerle çalışmaya devam etmesi demek. Lint uyarısını susturmak yerine effect'in neye bağlı olduğunu yeniden düşün.

Object ya da fonksiyon dependency'si: render içinde oluşturulan bir object her render'da yeni bir referanstır. Arama parametrelerini bir object'te toplayıp onu dependency yapan sürüm:

  // YANLIŞ: her render'da yeni bir object
  const params = { search: query.trim(), pageSize: 50 };
src/features/catalog/CatalogPage.tsx:61:7: warning react-hooks(exhaustive-deps): React hook useEffect depends on `params`, which changes every render
requests in the first 5 s without typing anything: 16

Kullanıcı hiçbir şeye dokunmadan beş saniyede 16 istek gitti: her yanıt setResult ile render tetikliyor, render yeni bir params object'i üretiyor, effect yeniden çalışıp yeni istek atıyor. Debounce olmasaydı sayı çok daha yüksek olurdu. Çözüm, dependency olarak primitive değerleri (query) kullanmak ya da object'i effect'in içinde oluşturmak.

Effect'i async yapmak: useEffect(async () => ...) bir promise döndürür, React ise cleanup fonksiyonu bekler. Async işi effect'in içinde ayrı bir fonksiyona (bizim örnekte timer callback'i) koy.

Cleanup'ı unutmak: timer, abonelik, event listener ve istekler cleanup olmadan sızıntıya dönüşür. StrictMode'un çift çalıştırması bu yüzden var.

Küçük Bir Deneme

★☆☆ Effect'e document.title'ı arama metnine göre güncelleyen bir satır ekle ("Search: Dune · Bookstore"). Bunun için ayrı bir effect mi yazmalısın, yoksa mevcut effect'e mi eklemelisin? Cleanup'ta başlığı geri almak gerekir mi?

★★☆ API'yi kapat ve bir arama yap. Ekranda ne görüyorsun? Hata durumuna bir "Retry" butonu ekle; butona basınca aynı query için effect'i yeniden çalıştırmanın temiz bir yolunu bul (ipucu: state'e bir sayaç ekleyip dependency yapmak).

★★★ Playwright ya da tarayıcının network throttling ayarıyla "Jane" yanıtını geciktir ve AbortController'ı kaldırıp yalnız ignore flag'iyle çalış. Network sekmesinde kaç yanıt indiriliyor? Sonra yalnız AbortController'la, catch'teki aborted kontrolü olmadan dene: kullanıcı ne görüyor?

Effect'i dış sistemle senkronizasyon için kullanmayı öğrendik. Sıradaki yazıda useRef ile DOM'a erişim ve render tetiklemeyen değişkenler, Angular'daki dependency injection'ın (DI) karşılığı olarak Context ve tekrar eden mantığı custom hook'lara taşımayı göreceğiz; arama effect'i de orada bir custom hook'a taşınmaya aday.