React Context API Performans Tuzakları
React'ın Context API'si prop drilling'i ortadan kaldırır, ama yanlış kurulumda tüm tüketici bileşenler gereksiz yere yeniden render olur. Provider değer stabilitesi, context bölümlendirme ve tüketici seçiciliği; bu sorunların önüne geçen üç temel noktadır.
Context API, prop drilling'i ortadan kaldırmak için tanıtılır ve ilk bakışta masum bir çözüm gibi görünür: veriyi bir kez tanımlarsınız, ihtiyaç duyan her bileşen doğrudan erişir. Ama bu kolaylığın arkasında, Redux veya Zustand gibi kütüphanelerin çözdüğü bir sorunu Context'in çözmediği gerçeği saklıdır: seçici (selector) mekanizması yoktur. Context değeri değiştiğinde, o Context'i tüketen her bileşen yeniden render olur; değerin hangi alanının değiştiği önemli değildir.
Küçük bir uygulamada bu fark hissedilmez. Ama tema, kullanıcı oturumu, form state'i veya bildirim sayacı gibi sık güncellenen verileri tek bir Context altında topladığınızda, ekranın tamamen ilgisiz bir köşesindeki bir bileşen de her güncellemede yeniden render olmaya başlar. Sorun genelde production'da değil, uygulama büyüdükçe ve Context'e bağımlı bileşen sayısı arttıkça fark edilir.
Aşağıda Context'in render davranışını gerçekte neyin belirlediği, hangi kurulumların sessizce performans sorunu ürettiği ve bu tuzaklardan kaçınmak için hangi yapısal kararların alınması gerektiği ele alınıyor.
Context Provider'ın value'su neden her render'da yeni referans üretir?
Context Provider'a geçirilen value prop'u bir nesne olduğunda, en sık karşılaşılan hata bu nesneyi doğrudan JSX içinde satır içi tanımlamaktır. Parent bileşen her render edildiğinde bu nesne yeniden oluşturulur; referans eşitliği testi başarısız olur ve React, Context'in değiştiğini varsayarak tüm tüketicileri yeniden render eder.
// Her render'da yeni nesne - tüm tüketiciler tetiklenir
function AppProvider({ children }) {
const [user, setUser] = useState(null);
return (
<AppContext.Provider value={{ user, setUser }}>
{children}
</AppContext.Provider>
);
}
// useMemo ile referans sabitlenir
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
useMemo eklemek burada gerçek bir kazanç üretir, çünkü karşılaştırma maliyeti son derece düşükken önlenen render sayısı büyük olabilir. Bu, useMemo'nun her yerde gerekli olmadığı genel kuralının istisnasıdır: Context value'sunda referans kararlılığı, bir optimizasyon tercihinden çok, doğru kurulumun bir parçasıdır. Ama useMemo tek başına yeterli değildir; bağımlılık dizisine user dışında bir değer daha eklendiğinde veya setUser her render'da yeniden tanımlandığında (state setter'ları hariç, çünkü onlar zaten sabittir) aynı sorun farklı bir yerden geri döner.
Tek bir Context içinde sık ve seyrek değişen veriler bir arada tutulduğunda ne olur?
Gerçek dünyada en sık görülen tuzak, tek bir "app context" içinde birbirinden bağımsız verileri toplamaktır: tema, dil tercihi, kullanıcı bilgisi, bildirim sayacı, hatta bazen form state'i. Bu verilerin güncelleme sıklığı çok farklıdır; tema nadiren değişirken bildirim sayacı saniyede birkaç kez artabilir.
// Tek context, farklı güncelleme sıklıkları bir arada
const AppContext = createContext();
// theme, language, user, notificationCount hepsi burada
// Bölünmüş context, her biri kendi güncelleme döngüsünde
const ThemeContext = createContext();
const NotificationContext = createContext();
const UserContext = createContext();
Tek Context yapısında, yalnızca tema bilgisini okuyan bir bileşen bile bildirim sayacı her arttığında yeniden render olur; çünkü Context'in tamamı tek bir değer olarak taşınır, ilgili bileşen sadece kendi alt kümesine abone olamaz. Context'i güncelleme sıklığına göre ayrı parçalara bölmek bu sorunu yapısal olarak çözer: sık değişen veri kendi Context'inde izole edilir, seyrek değişen veri başka bir Context'te kalır. Bu, Jotai ve Recoil'in atomic modelinin varsayılan olarak sunduğu granülerliğe Context API içinde manuel olarak ulaşmaya çalışmaktır; fark, bu bölünmeyi kütüphane değil geliştirici yapmak zorundadır.
useContext her tüketiciyi aynı önceliğe bağlar; selector mantığı yoktur
Redux'ta useSelector, Zustand'da store fonksiyonuna geçirilen seçici, state'in yalnızca ilgili dilimini okumanıza ve yalnızca o dilim değiştiğinde render olmanıza izin verir. Context API'de bu mekanizma yoktur; useContext çağrısı Context'in tamamına abone olur, kısmi abonelik desteklenmez.
Bu sınırlama bazen "Context'i seçici gibi kullanma" girişimleriyle aşılmaya çalışılır: value nesnesinin belirli bir alanını okuyup component içinde ayrıca memoize etmek. Ama bu yaklaşım render'ı engellemez, yalnızca render sonrası gereksiz DOM güncellemesini engeller; bileşen fonksiyonu yine de her Context değişiminde baştan çalışır. Gerçek bir seçici davranışı istendiğinde, ya Context'i daha küçük parçalara bölmek ya da use-context-selector gibi harici bir çözümle context aboneliğini manuel olarak kısıtlamak gerekir; bu ikinci seçenek ek bir bağımlılık maliyeti getirdiği için her projede tercih edilmez.
Context'i state yönetimi yerine yalnızca bağımlılık enjeksiyonu için kullanmanın sınırı nedir?
React dokümantasyonunun da vurguladığı bir ayrım var: Context, sık değişen state için değil, nadiren değişen ve geniş bir alt ağaç tarafından paylaşılan değerler için tasarlanmıştır. Tema, dil, kimlik doğrulama durumu gibi veriler bu profile uyar; bunlar bir kez set edilir ve uzun süre sabit kalır.
Sorun, Context'in "kolay erişilebilir global state" olarak kullanılmaya başlanmasıyla ortaya çıkar. Form alanlarının her tuş vuruşunda değişen değerini, bir listenin filtre state'ini veya sık güncellenen bir sayaç değerini Context üzerinden taşımak, Context'i tasarlandığı amacın dışına çıkarır. Bu tür sık değişen veriler için state yönetim kütüphaneleri veya bileşen ağacında daha aşağıda tutulan yerel state, Context'ten daha uygun bir araçtır. Context'i her yerde kullanmak yerine "bu veri gerçekten geniş bir ağaç tarafından mı paylaşılıyor, yoksa yalnızca birkaç bileşen mi ihtiyaç duyuyor" sorusunu sormak, prop drilling'den kaçınırken yeni bir render sorunu yaratmanın önüne geçer.
Provider'ları iç içe geçirmek render zincirini nasıl uzatır?
Uygulama büyüdükçe Context sayısı da artar ve bu Context'ler genellikle iç içe Provider'lar olarak bileşen ağacının en tepesinde birikir:
<ThemeProvider>
<UserProvider>
<NotificationProvider>
<SettingsProvider>
<App />
</SettingsProvider>
</NotificationProvider>
</UserProvider>
</ThemeProvider>
Bu yapının kendisi doğrudan bir performans sorunu değildir; her Provider yalnızca kendi tüketicilerini etkiler, bir üstteki Provider'ın render'ı diğerini otomatik olarak tetiklemez. Ama pratikte bu Provider'lar genelde tek bir dosyada, tek bir "AppProviders" bileşeninde toplanır ve bu bileşen her render edildiğinde (örneğin route değişiminde) tüm Provider ağacı yeniden değerlendirilir. Her Provider'ın value'su ayrı ayrı memoize edilmemişse, bu tek noktadan tetiklenen render zinciri, altındaki tüm Context'lerin tüketicilerine yayılır. Kontrol noktası basittir: her Provider kendi value'sunu bağımsız olarak memoize etmeli, Provider ağacının kendisi ise mümkün olduğunca sık render edilen bileşenlerin dışında, ağacın en tepesinde sabit kalmalıdır.
memo ile sarılmış bir bileşen Context güncellemesinden neden kaçamaz?
Bu, geliştiricilerin en çok şaşırdığı davranışlardan biridir: React.memo ile sarılmış bir bileşen, props değişmediği sürece parent render'ını atlar, ama kendisi bir Context'e useContext ile abone olduğunda bu koruma devre dışı kalır. Context değiştiğinde, memo'lu bileşen dahil, o Context'i tüketen her bileşen yeniden render olur; memo yalnızca parent'tan gelen prop değişikliklerine karşı korur, Context aboneliğine karşı hiçbir etkisi yoktur.
const ExpensiveWidget = React.memo(function ExpensiveWidget() {
const { theme } = useContext(AppContext);
// memo burada işe yaramaz: theme değişmese bile
// AppContext'teki başka bir alan değiştiğinde bu bileşen render olur
return <div className={theme}>...</div>;
});
Bu davranış, render maliyetini yapısal olarak düşürmenin neden memoization'dan önce geldiğini gösteren somut bir örnektir. Context tüketen bir bileşende asıl çözüm memo eklemek değil, Context'in kendisini o bileşenin gerçekten ihtiyaç duyduğu alanla sınırlamaktır. Bileşen yalnızca theme'e ihtiyaç duyuyorsa, tüm AppContext yerine ayrı bir ThemeContext'e abone olması, memo'nun tekrar anlamlı hale gelmesini sağlar.
Context güncellemesinin gerçek maliyetini ölçmek için hangi adımlar izlenir?
Context kaynaklı render sorunları genelde sezgiyle değil, ölçümle doğrulanır. React DevTools Profiler'da bir kayıt başlatıp Context'e bağlı bir state'i güncelleyin; render edilen bileşen listesinde beklenmedik girişler varsa, render nedeni sütununda genelde "context changed" ifadesi görünür. Bu, sorunun props zincirinde değil doğrudan Context aboneliğinde olduğunu doğrular.
İkinci adım, Context value'sunun kaç alan taşıdığını ve bu alanlardan kaçının gerçekten birlikte değiştiğini gözden geçirmektir. Bir Context beş alan taşıyorsa ve bu beş alan farklı zamanlarda güncelleniyorsa, bu güçlü bir bölme sinyalidir. Üçüncü adım, bölme veya memoization uyguladıktan sonra aynı Profiler kaydını tekrarlayıp önceki ve sonraki render sayısını karşılaştırmaktır; bu doğrulama olmadan yapılan değişiklik, gerçekten iyileşme sağlayıp sağlamadığı bilinmeyen bir tahmin olarak kalır.
Context API'nin kendisi hatalı bir araç değildir; sorun genelde onu her tür state paylaşımı için tek çözüm olarak kullanmaktan doğar. Value'yu memoize etmek, sık ve seyrek değişen veriyi ayırmak, ve gerçekten sık güncellenen state için Context dışında bir araç seçmek, bu üç karar birlikte uygulandığında Context'in prop drilling'i çözerken render maliyeti üretmesinin önüne geçer.