İçeriğe geç
Ali Osman Ilık
2 dk okuma

'Effect İçinde setState' Lint Hatasını useSyncExternalStore ile Çözmek

Bu sitede bir oyunlaştırma sistemi kurarken (localStorage'da tutulan bir puan durumu) aynı ESLint hatasına üç ayrı yerde çarptım:

Error: Calling setState synchronously within an effect can trigger
cascading renders
react-hooks/set-state-in-effect

Hatanın tipik şekli

En basit hali, mount olduktan sonra bir "hazır" bayrağı set eden klasik hydration-safe pattern:

const [mounted, setMounted] = useState(false);

useEffect(() => setMounted(true), []); // ❌ lint hatası

Bu pattern, sunucu ile ilk client render'ın aynı çıktıyı üretmesini sağlamak için çok yaygın kullanılır (localStorage gibi yalnızca tarayıcıda var olan bir şeyi okumadan önce). Ama React'in yeni lint kuralı, effect içinde doğrudan bir state setter çağırmayı — hatta bu çağrı başka bir fonksiyonun içinden yapılsa bile — sorunlu buluyor.

Çözüm: useSyncExternalStore

React'in kendi önerdiği alternatif, useSyncExternalStore — üç argüman alıyor: subscribe, getSnapshot (client için), ve getServerSnapshot (SSR için). React, hydration sırasında otomatik olarak getServerSnapshot'ı kullanıyor, hydration bittikten hemen sonra gerçek getSnapshot'a geçiyor. Sonuç: hydration mismatch yok, effect+setState yok.

function subscribe() {
  return () => {};
}

function useMounted() {
  return useSyncExternalStore(
    subscribe,
    () => true, // client: hep true
    () => false, // server: hep false
  );
}

Daha büyük ölçekte: modül seviyesinde bir store

Aynı prensip, localStorage'a yazan/okuyan daha karmaşık bir state için de işe yarıyor — sadece React state yerine modül seviyesinde mutable bir obje + dinleyici listesi kullanıyorsun:

let snapshot = { ids: [] as string[] };
let listeners: (() => void)[] = [];

export function subscribe(listener: () => void) {
  listeners.push(listener);
  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

export function getSnapshot() {
  return snapshot;
}

export function getServerSnapshot() {
  return { ids: [] };
}

export function unlock(id: string) {
  if (snapshot.ids.includes(id)) return;
  snapshot = { ids: [...snapshot.ids, id] };
  listeners.forEach((l) => l());
}

unlock artık bir React state setter değil, düz bir fonksiyon — istediğin yerden (bir effect'in içinden bile) güvenle çağırabilirsin, çünkü lint kuralının aradığı "setState çağrısı" deseni ortada yok.

Bonus: key ile "taze mount" tekniği

Bir input'un her açılışta sıfırlanması gerektiğinde (örneğin bir komut paleti), useEffect içinde setQuery("") çağırmak yerine, ilgili alt bileşeni değişen bir key ile sarmak işe yarıyor — React, key değiştiğinde bileşeni komple yeniden mount ediyor, useState'in başlangıç değeri doğal olarak sıfırlanıyor, hiçbir effect gerekmiyor.