React'te Global State: Context'in Sınırları, Zustand ve Ne Zaman Redux Toolkit?
Çoğu "global state" aslında server, URL ya da form state'idir; gerçekten global olan küçük client state için Zustand'ın selector'lı store'u Context'in her okuyanı render etme sorununu çözer, Redux Toolkit ise action log ve büyük ekip kuralları gerektiğinde gelir. Bookstore'da son bakılan kitapları ve sepet id'sini persist edilen bir store'a taşıyıp selector'sız okumanın 1 yerine 13 render ürettiğini ve yeni object döndüren selector'ın Zustand 5'te sonsuz döngüye girdiğini gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler React 19.3, Zustand 5.1, Redux Toolkit 2.14, React Router 8.5, TanStack Query 5.107, TypeScript 7.0, Vite 8.3 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ış, render sayıları ve localStorage içeriği Playwright 1.65 ile headless Chromium'da gözlenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React istemcisinde son birkaç yazıda state farklı yerlere dağıldı. Bu yazıda katalog sayfasına "Recently viewed" adlı bir kutu ekliyoruz: kullanıcının son açtığı beş kitap, sayfa yenilense de kaybolmuyor. Bu liste ve sunucudaki sepetin id'si aynı Zustand store'unda duruyor.
Kutunun altında bir "Clear history" butonu var. Basınca kaç component yeniden render edilir? Sepet özetini, katalog sayfasını ve kitap kartlarını render etmenin bir sebebi yok; sepete dokunulmadı. Cevap, store'u nasıl okuduğuna bağlı ve iki okuma biçimi arasındaki fark 1 ile 13 render.
Önce Sınıflandır: Bu State Gerçekten Global mi?
"Global state" denince çoğu ekip tek bir büyük store hayal ediyor. Bookstore'da şu ana kadar biriken state'e türüne göre bakınca tablo farklı:
- Server state: kitaplar, kitap detayı, sepetin içeriği, siparişler. Doğrusu sunucuda; istemcide yalnız bir cache'i var. Sekizinci yazıdan beri TanStack Query'de.
- URL state: arama metni ve sayfa numarası. Paylaşılabilmeli ve geri tuşuyla dönülebilmeli. Dokuzuncu yazıdan beri query string'de.
- Form state: sipariş formunun alanları ve hataları. Formla doğup formla ölüyor. Onuncu yazıdan beri react-hook-form'da.
- Yerel UI state: bir kartta seçilen adet, hediye notu. Bir component'e ait;
useState. - Client state: tema tercihi, giriş yapmış kullanıcı, sepetin id'si, son bakılan kitaplar. Sunucuda yok, birden çok ekran okuyor.
Gerçekten "global state yönetimi" gereken yalnız son madde ve o da küçük. Angular'da State Yönetimi serisinin ilk yazısı "Angular'da State Nedir ve Nerede Yaşamalı? Server, UI, URL ve Form State'ini Ayırmak" aynı ayrımla başlıyordu; framework değişse de soru aynı. Server state'i bir global store'a kopyalamak bu serideki en pahalı hata olurdu: invalidation, retry ve cache süresi gibi TanStack Query'nin çözdüğü her şeyi store'da yeniden yazmak gerekirdi.
Context'in Sınırı
Yedinci yazıda tema, kullanıcı ve sepeti üç ayrı Context'e koymuştuk ve şu sonucu ölçmüştük: Context'i okuyan her component, Context'in değeri değişince render ediliyor; Context'te "yalnız şu alanı izle" demenin yolu yok. Az değişen değerler (tema, dil) için bu sorun değil. Sık değişen ve farklı parçaları farklı component'lerce okunan bir değer için iki seçenek var: Context'i giderek daha küçük parçalara bölmek ya da seçici abonelik sunan bir store kullanmak.
Dokuzuncu yazıda bir sebep daha çıktı: router'ın middleware'i ve loader'ları React ağacının dışında çalışıyor ve Context okuyamıyor. Bu yüzden kullanıcıyı ve sepet id'sini useSyncExternalStore ile okunan iki küçük el yapımı store'a taşımıştık. O iki dosya çalışıyordu ama aboneleri elle tutuyor, seçici abonelik ve localStorage'a yazma gibi şeyleri her biri için tekrar tekrar yazmamızı istiyordu. Zustand tam bu boşluğu dolduran küçük bir kütüphane.
Zustand Store ve Selector'lar
npm install zustand@5.1.0
import { create } from 'zustand';
export type User = { name: string; email: string };
type AuthState = {
user: User | null;
signIn: (user: User) => void;
signOut: () => void;
};
// Demo giriş, yalnız bellekte. Component'ler selector ile okur; router middleware'i useAuthStore.getState() ile okur
export const useAuthStore = create<AuthState>()((set) => ({
user: null,
signIn: (user) => set({ user }),
signOut: () => set({ user: null }),
}));
create bir hook döndürüyor: useAuthStore. Store bir module seviyesinde tanımlı, provider yok; Angular'daki providedIn: 'root' bir service'e daha çok benziyor. Zustand React'e yedinci yazıda gördüğümüz useSyncExternalStore ile bağlanıyor. Component'ler store'u bir selector fonksiyonuyla okuyor:
export function UserMenu() {
const user = useAuthStore((state) => state.user);
const signOut = useAuthStore((state) => state.signOut);
Selector'ın döndürdüğü değer değişmedikçe component render edilmiyor; karşılaştırma Object.is ile. React dışından okumak için getState() var; dokuzuncu yazıdaki giriş kontrolü middleware'i artık böyle:
// Runs before the route's loader/action on every navigation to it: the counterpart of a CanActivate guard
export const requireUser: MiddlewareFunction = ({ request, context }) => {
const user = useAuthStore.getState().user;
Sepet id'si ve son bakılan kitaplar ise sayfa yenilenince de yaşamalı. Bunun için ikinci bir store ve persist middleware'i:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type RecentBook = { isbn: string; title: string };
type ClientState = {
cartId: string | null;
recentlyViewed: RecentBook[];
setCartId: (cartId: string | null) => void;
addRecentlyViewed: (book: RecentBook) => void;
clearRecentlyViewed: () => void;
};
// Sayfa yenilense de yaşaması gereken client state: sunucudaki hangi sepetin bizim olduğu ve son açılan kitaplar
export const useClientStore = create<ClientState>()(
persist(
(set) => ({
cartId: null,
recentlyViewed: [],
setCartId: (cartId) => set({ cartId }),
addRecentlyViewed: (book) =>
set((state) => ({
recentlyViewed: [book, ...state.recentlyViewed.filter((b) => b.isbn !== book.isbn)].slice(0, 5),
})),
clearRecentlyViewed: () => set({ recentlyViewed: [] }),
}),
{
name: 'bookstore.client', // localStorage anahtarı
version: 1,
// Yalnız veriyi sakla; fonksiyonları store yeniden oluşturur
partialize: (state) => ({ cartId: state.cartId, recentlyViewed: state.recentlyViewed }),
},
),
);
partialize yalnız veriyi localStorage'a yazıyor; fonksiyonlar her yüklemede yeniden oluşuyor. version, store'un şekli değiştiğinde eski kayıtları migrate fonksiyonuyla dönüştürmeyi mümkün kılıyor. Kitap sayfasının loader'ı her açılışta listeye ekliyor; loader React dışında olduğu için yine getState() kullanıyor:
// Resolver gibi: route ancak kitap cache'e girdikten sonra render edilir
export async function bookLoader({ params }: LoaderFunctionArgs) {
try {
const book = await queryClient.ensureQueryData(bookQuery(params.isbn!));
// Loader'lar React dışında çalışır: getState() store'a hook olmadan ulaşır
useClientStore.getState().addRecentlyViewed({ isbn: book.isbn, title: book.title });
Okuyan taraf iki selector'lı küçük bir hook ve listeyi gösteren component:
import { useClientStore } from '@/shared/clientStore';
export function useCartId() {
// İki selector: component store'daki her değişiklikte değil, yalnız cartId değişince render edilir
const cartId = useClientStore((state) => state.cartId);
const setCartId = useClientStore((state) => state.setCartId);
return { cartId, setCartId };
}
import { Link } from 'react-router';
import { useClientStore } from '@/shared/clientStore';
export function RecentlyViewed() {
const recent = useClientStore((state) => state.recentlyViewed);
const clear = useClientStore((state) => state.clearRecentlyViewed);
if (recent.length === 0) {
return null;
}
return (
<aside className="recent" aria-label="Recently viewed">
<h2>Recently viewed</h2>
<ul>
{recent.map((book) => <li key={book.isbn}><Link to={`/books/${book.isbn}`}>{book.title}</Link></li>)}
</ul>
<button type="button" className="link" onClick={clear}>Clear history</button>
</aside>
);
}
Dune ve Emma'yı açıp kataloğa döndük, Dune'u sepete ekledik ve sayfayı yeniledik:
first visit | recently viewed: (none) | localStorage: null
after browsing | recently viewed: Emma, Dune | Cart: 1 item(s) · ₺489.00
localStorage: {"state":{"cartId":"{cartId}","recentlyViewed":[{"isbn":"9780141439587","title":"Emma"},{"isbn":"9780441172719","title":"Dune"}]},"version":1}
after reload | recently viewed: Emma, Dune | Cart: 1 item(s) · ₺489.00
İlk ziyarette localStorage'da kayıt yoktu. Gezinmeden sonra bookstore.client anahtarında yalnız partialize'ın seçtiği iki alan ve sürüm numarası var. Yenilemeden sonra liste de sepet de yerindeydi: Zustand localStorage'ı senkron okuduğu için ilk render'da veri hazırdı ve sepet query'si doğru id ile çalıştı.
Seçici Abonelik Ölçümü
Önce Tahmin Et'teki soruyu production build'de iki sürümle ölçtük. İlki snapshot'taki kod: useCartId store'u iki selector ile okuyor. İkincisi aynı hook'un selector'sız hâli:
export function useCartId() {
// YANLIŞ: selector yok, bir Context gibi bütün store'a abone olur
const { cartId, setCartId } = useClientStore();
return { cartId, setCartId };
}
selectors: "Clear history": RecentlyViewed
no selector: "Clear history": CartSummary, CatalogPage×2, RecentlyViewed, BookList, BookCard×8
Selector'lı sürümde yalnız listeyi gösteren RecentlyViewed render edildi. Selector'sız sürümde useClientStore() bütün store'a abone oldu; store'un herhangi bir alanı değişince useCart'ı çağıran her component (sepet özeti, katalog sayfası) ve onların altındaki liste ve sekiz kart render edildi, toplam 13 render. Bu, Context'in davranışının aynısı. Zustand'ı kullanmak tek başına performans getirmiyor; kazanç, selector'ı dar tutmaktan geliyor.
Selector'da Yeni Object Döndürmek ve useShallow
İki selector yazmak yerine ikisini bir object'te döndürmek doğal görünüyor:
export function useCartId() {
// YANLIŞ: selector her çağrıda yeni bir object üretir
return useClientStore((state) => ({ cartId: state.cartId, setCartId: state.setCartId }));
}
console.error: The result of getSnapshot should be cached to avoid an infinite loop
console.error: React Router caught the following error during render Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
catalog on screen: false
Uygulama hiç açılmadı. Selector her çağrıda yeni bir object döndürüyor; useSyncExternalStore art arda iki okumada farklı referans görünce store'un değiştiğini sanıyor, render tetikliyor, render yeni bir object üretiyor. React önce snapshot'ın cache'lenmesi gerektiği uyarısını verdi, sonra "Maximum update depth exceeded" ile render'ı durdurdu ve hatayı React Router'ın error boundary'si yakaladı. Zustand 5'in geçiş rehberi, selector'ın yeni bir referans döndürmesinin sonsuz döngüye yol açabileceğini açıkça yazıyor ve çözüm olarak useShallow'u gösteriyor:
import { useShallow } from 'zustand/shallow';
import { useClientStore } from '@/shared/clientStore';
export function useCartId() {
// useShallow yeni object'in alanlarını öncekiyle karşılaştırır, aynıysa eski referansı korur
return useClientStore(useShallow((state) => ({ cartId: state.cartId, setCartId: state.setCartId })));
}
catalog on screen: true
useShallow yeni object'in alanlarını öncekiyle tek tek karşılaştırıyor ve aynıysa eski referansı döndürüyor. Snapshot'ta iki ayrı selector'ı tercih ettik; ikisi de doğru, ilki bir import ve bir kavram daha az.
Redux Toolkit'in Yeri
Redux, Flux mimarisinin en yaygın uygulaması; UI Mimari Pattern'leri serisindeki "Flux ve Unidirectional Data Flow: Two-Way Binding Neden Ölçeklenmedi?" yazısında anlattığımız tek yönlü akışın kendisi. Redux Toolkit (RTK) bugün Redux yazmanın önerilen yolu. "Recently viewed" listesinin RTK karşılığını küçük bir Node script'i olarak yazıp çalıştırdık; uygulamaya eklemedik:
import { configureStore, createSlice, type Middleware, type PayloadAction } from '@reduxjs/toolkit';
type RecentBook = { isbn: string; title: string };
// Bir slice: state, reducer'lar ve üretilen action creator'lar tek yerde
const recentSlice = createSlice({
name: 'recent',
initialState: [] as RecentBook[],
reducers: {
viewed(state, action: PayloadAction<RecentBook>) {
// Immer: mutation gibi görünen kod yeni ve değişmez bir state üretir
const index = state.findIndex((b) => b.isbn === action.payload.isbn);
if (index >= 0) {
state.splice(index, 1);
}
state.unshift(action.payload);
state.splice(5);
},
cleared: () => [],
},
});
export const { viewed, cleared } = recentSlice.actions;
// Her değişiklik adı olan bir action olarak dispatch'ten geçer: log'lamak, tekrar oynatmak ve DevTools'ta incelemek kolay
const actionLog: string[] = [];
const logger: Middleware = () => (next) => (action) => {
actionLog.push(JSON.stringify(action));
return next(action);
};
const store = configureStore({
reducer: { recent: recentSlice.reducer },
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
});
store.dispatch(viewed({ isbn: '9780441172719', title: 'Dune' }));
store.dispatch(viewed({ isbn: '9780141439587', title: 'Emma' }));
store.dispatch(viewed({ isbn: '9780441172719', title: 'Dune' }));
console.log('state:', store.getState().recent.map((b) => b.title).join(', '));
console.log(actionLog.join('\n'));
$ node recentSlice.ts
state: Dune, Emma
{"type":"recent/viewed","payload":{"isbn":"9780441172719","title":"Dune"}}
{"type":"recent/viewed","payload":{"isbn":"9780141439587","title":"Emma"}}
{"type":"recent/viewed","payload":{"isbn":"9780441172719","title":"Dune"}}
Zustand'dan farkı çıktının ikinci kısmında. Her değişiklik adı olan bir action olarak dispatch'ten geçiyor; bu akış log'lanabiliyor, Redux DevTools'ta adım adım geri sarılabiliyor ve bir hata raporuna "kullanıcı şu action'ları şu sırayla yaptı" diye eklenebiliyor. Bedeli daha fazla kavram: slice, action, reducer, store, middleware ve React tarafında bir provider. RTK Query de sekizinci yazıda TanStack Query ile yaptığımız işi Redux store'unun içinde yapıyor.
Angular tarafında bu ikili tanıdık. Zustand'ın sade store'u NgRx Signal Store'a ya da signal tutan bir service'e, RTK ise klasik NgRx Store'a (action, reducer, selector, effect) karşılık geliyor. Angular'da State Yönetimi serisinin son yazısı "Doğru State Aracını Seçmek ve Angular State'in Evrimi: BehaviorSubject'ten Signal Store'a" aynı soruyu Angular için cevaplıyor.
Karar Tablosu
| State | İlk tercih | Ne zaman bir sonrakine geç |
|---|---|---|
| Tek component'e ait | useState / useReducer | Kardeş bir component de okumaya başlayınca |
| Birkaç yakın component | Ortak parent'a taşı (lifting state up) | Props üç dört seviyeden fazla aşağı inmeye başlayınca |
| Az değişen, çok yerde okunan (tema, dil) | Context | Sık değişmeye başlayınca ya da React dışından okunması gerekince |
| Sunucudan gelen veri | TanStack Query (ya da router loader'ı) | Hiçbir zaman global store'a kopyalama |
| Paylaşılabilir görünüm (arama, filtre, sayfa) | URL search params | Gizli ya da çok büyük olduğunda |
| Form alanları | Form action'ları ya da react-hook-form | Form bitince state'i de bitir |
| Sık değişen, çok yerde okunan client state | Zustand (selector'larla) | Değişiklik geçmişi, action log ve büyük bir ekip kuralı gerekince |
| Olay akışı önemli, büyük ve çok ekipli uygulama | Redux Toolkit | - |
Tablonun okunuşu yukarıdan aşağı: bir satırdaki araç yetmediğinde bir alttakine in. Çoğu uygulama ortadaki satırlarda kalır; Bookstore'un bütün global client state'i on satırlık iki store.
Üretimde Dikkat
localStorage'a yazdığın her şey o tarayıcıda, şifrelenmeden ve aynı origin'deki her script'in okuyabileceği bir yerde durur. Sepet id'si ve son bakılan kitaplar için bu kabul edilebilir; token, kişisel veri ya da yetki bilgisi için değil. XSS'e (Cross-Site Scripting; sayfaya saldırganın script'inin enjekte edilmesi) karşı localStorage'ın neden zayıf bir yer olduğunu ve alternatifi olan BFF (Backend for Frontend; frontend'e özel backend katmanı) yaklaşımını Frontend Güvenliği serisindeki "Token Nerede Saklanmalı? localStorage, Memory, Cookie, Service Worker ve BFF'in Tehdit Modeli" yazısı ayrıntılı anlatıyor.
Persist edilen şeklin bir sözleşme olduğunu unutma. Bir alanın adını değiştirdiğinde eski kayıtlar yeni koda yüklenir; version'ı artırıp migrate yaz ya da eski kaydı bilerek yok say. Bizim değişikliğimiz de böyle bir geçiş: önceki yazılardaki bookstore.cartId anahtarı artık okunmuyor, bu sürüme geçen bir kullanıcının sepeti tarayıcıda "unutulur" (sunucuda durmaya devam eder). Gerçek bir uygulamada bu tek seferlik taşımayı yazmak gerekirdi.
Store'lar module seviyesinde yaşadığı için testler arasında state taşınır. Her testten önce store'u başlangıç hâline döndürmek gerekiyor. On üçüncü yazıda testleri yazarken buna döneceğiz. Aynı sebeple server-side rendering'de module seviyesindeki bir store bütün kullanıcılar arasında paylaşılır; on beşinci yazıdaki Next.js'te store'u istek başına oluşturmak gerekiyor.
Sık Düşülen Tuzaklar
Store'u selector'sız okumak: const { a, b } = useStore() bütün store'a abone olur ve Context'in sorununu geri getirir.
Selector'da yeni object ya da array döndürmek: Zustand 5'te bu sonsuz döngüdür. Ayrı selector'lar yaz ya da useShallow kullan. Selector içinde filter ya da map yapmak da her seferinde yeni bir array üretir; bunu selector'ın dışında, render sırasında yap.
Server state'i store'a koymak: kitap listesini Zustand'a yazıp ekranları oradan beslemek, TanStack Query cache'inin yanında ikinci bir kopya demek. Invalidation birini günceller, diğeri eski kalır.
Her şeyi tek bir store'a toplamak: tema, kullanıcı ve sepet id'sinin aynı store'da olması teknik olarak sorun değil (selector'lar korur), ama farklı yaşam döngülerini karıştırır: biri persist edilir, biri edilmez; biri çıkışta temizlenir, biri temizlenmez. Küçük ve amaca özel store'lar daha kolay okunuyor.
Küçük Bir Deneme
★☆☆ Tema tercihini (ThemeProvider'daki choice) useClientStore'a taşı ve persist et. Sayfa yenilenince seçilen tema korunuyor mu? Context'i tamamen kaldırabilir misin, yoksa yine de bir yerde kalması mantıklı mı?
★★☆ Çıkış yapınca sepet id'sini ve son bakılan kitapları temizleyen bir akış yaz. Bu mantık nerede durmalı: signOut'un içinde mi, bir component'te mi, Zustand'ın subscribe'ı ile store'lar arasında mı?
★★★ clientStore'un version'ını 2 yap ve recentlyViewed'e kitabın yazarını ekle. Eski kayıtlar için yazarı boş bırakan bir migrate fonksiyonu yaz; önceki yazılardaki bookstore.cartId anahtarını da yeni store'a taşıyan tek seferlik bir kod ekle. Playwright ile eski bir kaydı localStorage'a yazıp sayfayı açarak dene.
State'in nerede yaşadığını netleştirdik ve gereksiz render'ların bir kısmını selector'larla önledik. Ama Bookstore'da hâlâ ölçmediğimiz render'lar var: sepete her eklemede sekiz kitap kartının hepsi render ediliyor. Sıradaki yazıda bu render'ların gerçekten bir maliyeti olup olmadığını Profiler ile ölçecek, memo, useMemo ve useCallback'in ne zaman işe yaradığını görecek ve React Compiler'ı açıp aynı ölçümü tekrar yapacağız.