React Concurrent Stores: Tearing Problemine React Core Çözümü
React 18 ile birlikte gelen Concurrent Rendering, kullanıcı deneyimini iyileştiren devrim niteliğinde bir özellik oldu. Ancak bu güçlü özellik beraberinde beklenmedik bir sorunu da getirdi: tearing. Bu yazıda tearing probleminin kökenini, neden sadece concurrent modda ortaya çıktığını ve React ekibinin useSyncExternalStore API'siyle sunduğu zarif çözümü derinlemesine inceleyeceğiz.
Tearing Nedir?
Tearing (yırtılma), bir kullanıcı arayüzünde aynı veri kaynağına bağlı farklı bileşenlerin farklı değerler göstermesi durumudur. Yani ekranda aynı anda tutarsız veriler görüntülenir — sanki arayüz "yırtılmış" gibi bir etki yaratır.
Bir e-ticaret uygulaması düşünün: Sepet simgesinde "3 ürün" yazarken, sepet sayfasında "5 ürün" görüyorsunuz. Aynı store'dan okunan veri, farklı render anlarında farklı değerlere sahip olduğu için bu tutarsızlık oluşur.
Senkron Rendering'de Neden Sorun Yoktu?
React 17 ve öncesinde rendering işlemi senkron çalışıyordu. React bir render başlattığında, tüm bileşen ağacını kesintisiz olarak tek seferde işliyordu. Bu sayede tüm bileşenler aynı "snapshot" üzerinden okuma yapıyordu.
Senkron Rendering:
Store değeri: 1
├── Bileşen A okur → 1
├── Bileşen B okur → 1
└── Bileşen C okur → 1
✅ Tutarlı: Hepsi aynı değeri görürConcurrent Rendering'de Ne Değişti?
Concurrent modda React, rendering işlemini bölebilir (interruptible rendering). Yüksek öncelikli bir güncelleme geldiğinde, mevcut render duraklatılabilir. İşte tam bu duraklatma anında harici bir store güncellenirse, kalan bileşenler eski değeri değil yeni değeri okur.
Concurrent Rendering:
Store değeri: 1
├── Bileşen A okur → 1
│ ⏸️ [RENDER DURAKLATILDI - Store değeri 2 oldu]
├── Bileşen B okur → 2 ← YENİ DEĞER!
└── Bileşen C okur → 2 ← YENİ DEĞER!
❌ Tutarsız: Tearing oluştu!Problemi Somutlaştıralım
Tearing problemini anlamak için basit bir harici store (external store) oluşturalım:
// Basit bir harici store
let listeners = [];
let state = { color: 'blue' };
const store = {
getState() {
return state;
},
setState(newState) {
state = newState;
// Tüm dinleyicileri bilgilendir
listeners.forEach(listener => listener());
},
subscribe(listener) {
listeners.push(listener);
return () => {
listeners = listeners.filter(l => l !== listener);
};
}
};Şimdi bu store'u kullanan bir bileşen yazalım — sorunlu yaklaşımla:
import { useState, useEffect } from 'react';
function ColorBox() {
const [color, setColor] = useState(store.getState().color);
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setColor(store.getState().color);
});
return unsubscribe;
}, []);
// Ağır bir hesaplama simüle edelim
let now = performance.now();
while (performance.now() - now < 50) {
// Render'ı yavaşlat — concurrent mod'un
// render'ı bölme ihtimalini artırır
}
return (
<div style={{
width: 50,
height: 50,
backgroundColor: color
}} />
);
}
function App() {
return (
<div style={{ display: 'flex', gap: 4 }}>
{Array.from({ length: 100 }, (_, i) => (
<ColorBox key={i} />
))}
</div>
);
}Bu senaryoda 100 tane ColorBox bileşeni render edilirken, ortadan bir yerde store güncellenirse, bazı kutular "blue" bazıları ise yeni rengi gösterir. İşte bu tearing'dir.
React Ekibinin Çözümü: useSyncExternalStore
React 18 ile birlikte tanıtılan useSyncExternalStore hook'u, tearing problemini React core seviyesinde çözmek için tasarlandı. Bu API, harici store'lardan okuma yapmanın "resmi" ve güvenli yoludur.
API İmzası
const snapshot = useSyncExternalStore(
subscribe, // Store'a abone olma fonksiyonu
getSnapshot, // Mevcut değeri döndüren fonksiyon
getServerSnapshot? // SSR için opsiyonel snapshot
);Temel Çalışma Prensibi
useSyncExternalStore'un tearing'i önleme mekanizması şu şekilde çalışır:
- Render sırasında snapshot alır ve bu snapshot'ı "kilitler"
- Render tamamlanmadan store değişirse, React tüm render'ı baştan başlatır (senkron modda)
- Bu sayede tüm bileşenler aynı snapshot'ı görür
import { useSyncExternalStore } from 'react';
function ColorBox() {
const color = useSyncExternalStore(
store.subscribe,
() => store.getState().color
);
let now = performance.now();
while (performance.now() - now < 50) {
// Ağır hesaplama — ama artık tearing olmaz!
}
return (
<div style={{
width: 50,
height: 50,
backgroundColor: color
}} />
);
}Bu basit değişiklikle tearing problemi tamamen ortadan kalkar.
Kendi Store'unuzu Oluşturma
useSyncExternalStore ile uyumlu, üretim kalitesinde bir store oluşturalım:
type Listener = () => void;
type SetterFn<T> = (prev: T) => T;
function createStore<T>(initialState: T) {
let state = initialState;
const listeners = new Set<Listener>();
function getSnapshot(): T {
return state;
}
function setState(updater: T | SetterFn<T>) {
const nextState =
typeof updater === 'function'
? (updater as SetterFn<T>)(state)
: updater;
// Referans eşitliği kontrolü — gereksiz render'ları önle
if (Object.is(state, nextState)) return;
state = nextState;
listeners.forEach(listener => listener());
}
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
return { getSnapshot, setState, subscribe };
}Selector Desteği Ekleme
Büyük store'larda her değişiklikte tüm bileşenlerin yeniden render edilmesini istemeyiz. Selector pattern ile sadece ilgili veriler değiştiğinde render tetiklenir:
import { useSyncExternalStore, useCallback, useRef } from 'react';
function useStoreSelector<T, S>(
store: ReturnType<typeof createStore<T>>,
selector: (state: T) => S
): S {
const selectorRef = useRef(selector);
selectorRef.current = selector;
const getSnapshot = useCallback(() => {
return selectorRef.current(store.getSnapshot());
}, [store]);
return useSyncExternalStore(
store.subscribe,
getSnapshot
);
}Kullanım Örneği
// Store tanımı
interface AppState {
user: { name: string; email: string } | null;
theme: 'light' | 'dark';
notifications: number;
cart: { items: string[]; total: number };
}
const appStore = createStore<AppState>({
user: null,
theme: 'light',
notifications: 0,
cart: { items: [], total: 0 },
});
// Bileşenlerde kullanım
function Header() {
const theme = useStoreSelector(appStore, s => s.theme);
const notifications = useStoreSelector(appStore, s => s.notifications);
return (
<header className={`header-${theme}`}>
<span>🔔 {notifications}</span>
<button onClick={() =>
appStore.setState(prev => ({
...prev,
theme: prev.theme === 'light' ? 'dark' : 'light'
}))
}>
Tema Değiştir
</button>
</header>
);
}
function CartBadge() {
const itemCount = useStoreSelector(
appStore,
s => s.cart.items.length
);
return <span className="badge">{itemCount}</span>;
}
function CartPage() {
const cart = useStoreSelector(appStore, s => s.cart);
return (
<div>
<h2>Sepetim ({cart.items.length} ürün)</h2>
<p>Toplam: {cart.total} TL</p>
{cart.items.map((item, i) => (
<div key={i}>{item}</div>
))}
</div>
);
}Artık CartBadge ve CartPage bileşenleri hiçbir zaman farklı sepet değerleri göstermez — concurrent rendering altında bile.
SSR (Server-Side Rendering) Desteği
useSyncExternalStore'un üçüncü parametresi, sunucu tarafında rendering yapılırken kullanılır:
function ThemeProvider({ children }) {
const theme = useSyncExternalStore(
themeStore.subscribe,
() => themeStore.getSnapshot(), // Client
() => 'light' // Server — varsayılan değer
);
return (
<div data-theme={theme}>
{children}
</div>
);
}Bu parametre sağlanmazsa ve bileşen sunucuda render edilmeye çalışılırsa, React bir hata fırlatır. SSR kullanan projelerde bu parametreyi ihmal etmemek kritik öneme sahiptir.
Popüler Kütüphaneler ve useSyncExternalStore
Pek çok popüler state management kütüphanesi bu API'yi entegre etmiştir:
Zustand
import { create } from 'zustand';
// Zustand v4+ useSyncExternalStore kullanır
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));Redux Toolkit
import { useSelector } from 'react-redux';
// react-redux v8+ useSyncExternalStore kullanır
function Counter() {
const count = useSelector(state => state.counter.value);
return <span>{count}</span>;
}Jotai, Valtio
Bu kütüphaneler de React 18 uyumluluğu kapsamında useSyncExternalStore veya eşdeğer mekanizmaları benimsemiştir.
Dikkat Edilmesi Gereken Noktalar
1. getSnapshot Referans Kararlılığı
getSnapshot fonksiyonunun her çağrıda yeni bir referans döndürmesi sonsuz döngüye yol açabilir:
// ❌ YANLIŞ — her seferinde yeni obje oluşturur
const data = useSyncExternalStore(
store.subscribe,
() => ({ ...store.getState() }) // Her çağrıda yeni referans!
);
// ✅ DOĞRU — aynı referansı döndürür
const data = useSyncExternalStore(
store.subscribe,
() => store.getState() // Değişmediği sürece aynı referans
);2. Subscribe Fonksiyonunun Kararlılığı
subscribe fonksiyonu her render'da yeni bir referansa sahip olmamalıdır:
// ❌ YANLIŞ
function Component() {
const value = useSyncExternalStore(
(cb) => store.subscribe(cb), // Her render'da yeni fonksiyon
store.getSnapshot
);
}
// ✅ DOĞRU
function Component() {
const value = useSyncExternalStore(
store.subscribe, // Kararlı referans
store.getSnapshot
);
}3. Senkron Fallback Maliyeti
useSyncExternalStore, tearing tespit ettiğinde render'ı senkron moda düşürür. Bu, concurrent rendering'in avantajlarından kısmen vazgeçmek anlamına gelir. Ancak bu tutarlılık için ödenmesi gereken bir bedeldir ve React ekibi bu trade-off'u bilinçli olarak yapmıştır.
Performans Optimizasyonu İpuçları
Selector fonksiyonlarında gereksiz yeniden render'ları önlemek için şu stratejileri uygulayabilirsiniz:
import { useSyncExternalStore, useMemo } from 'react';
// Shallow equality ile karşılaştırma
function useStoreWithShallowEqual<T, S>(
store: ReturnType<typeof createStore<T>>,
selector: (state: T) => S,
equalityFn: (a: S, b: S) => boolean = Object.is
): S {
const prevRef = useRef<S>();
const getSnapshot = useCallback(() => {
const next = selector(store.getSnapshot());
if (prevRef.current !== undefined && equalityFn(prevRef.current, next)) {
return prevRef.current; // Önceki referansı koru
}
prevRef.current = next;
return next;
}, [store, selector, equalityFn]);
return useSyncExternalStore(store.subscribe, getSnapshot);
}Sonuç
Tearing problemi, React'in concurrent rendering mimarisinin kaçınılmaz bir yan etkisiydi. Harici store'lar React'in kontrol alanının dışında kaldığından, render bölündüğünde tutarsız veri okumaları meydana geliyordu.
useSyncExternalStore, bu soruna React core seviyesinde zarif bir çözüm sunuyor:
- Tearing'i garantili olarak önler — tüm bileşenler aynı snapshot'ı görür
- Minimal API yüzeyine sahiptir — sadece
subscribevegetSnapshotyeterli - SSR uyumludur — üçüncü parametre ile sunucu tarafı desteği sağlar
- Ekosistem tarafından benimsenmiştir — Zustand, Redux, Jotai gibi kütüphaneler zaten kullanıyor
Eğer kendi state management çözümünüzü yazıyorsanız veya mevcut bir harici veri kaynağını React ile entegre ediyorsanız, useSyncExternalStore kullanmanız zorunluluk seviyesindedir. Bu API olmadan, concurrent rendering altında kullanıcılarınız tutarsız arayüzlerle karşılaşabilir — ve bu tür hataların kaynağını bulmak son derece zordur.
React'in concurrent geleceğinde güvenli ve performanslı uygulamalar geliştirmek istiyorsanız, useSyncExternalStore'u araç kutunuzun vazgeçilmez bir parçası haline getirin.