React'te Veri Çekmek: fetch + Effect'ten TanStack Query'ye, Suspense ve use()
Effect ile veri çekmek cache, deduplication, retry ve invalidation vermez; TanStack Query bunları query key etrafında toplar. Bookstore kataloğunu ve sunucu tarafı sepeti TanStack Query'ye taşıyıp geri dönülen aramanın cache'ten geldiğini, optimistic update'in 500'de geri alındığını, 409 Problem Details'ın kullanıcıya gösterildiğini, Suspense'in 300 ms'lik gösterim aralığını ve render içinde oluşturulan promise'in use() ile üç saniyede 3.739 istek attığını ölçüyoruz.
Sürüm notu: Bu yazıdaki örnekler React 19.3, 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ış, network ve ekran 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ğunda kullanıcı "Dune" arıyor, sonra kutuyu boşaltıyor, sonra yine "Dune" yazıyor. Altıncı yazıdaki effect'li sürüm bu üç adımda API'ye üç istek atıyordu. Bu yazıda aramayı TanStack Query'ye taşıyoruz ve cache'e 30 saniyelik bir staleTime veriyoruz. Aynı üç adımda kaç istek gider?
İkinci soru sepetle ilgili. Sepet artık tarayıcıda değil, Bookstore API'de (/api/carts) tutuluyor. Kullanıcı "Add" butonuna basıyor ve sunucu 1,5 saniye sonra cevap veriyor; header'daki "Cart: 2 item(s)" yazısı ne zaman değişmeli? Sunucu 500 dönerse ne olmalı?
Effect ile Veri Çekmenin Eksikleri
Elimizdeki useBookSearch doğru çalışıyor: debounce var, eski istek iptal ediliyor, race condition yok. Ama bir veri katmanından beklediğimiz şeylerin çoğu eksik. Cache yok, bu yüzden geri dönülen her arama yeniden isteniyor. Aynı veriyi iki component isterse iki istek gidiyor; sepeti header da sepet paneli de okuyor. Sayfa değiştirince eski sonuç kayboluyor ve ekran bir anlığına boşalıyor. Hata olunca tekrar deneme yok. Bir veri değiştiğinde (sipariş verildi, stok düştü) hangi ekranların yenilenmesi gerektiğini bilen bir yer yok. İç içe component'ler kendi effect'leriyle sırayla veri çekiyorsa istekler birbirini bekliyor (waterfall).
Bunların her biri elle çözülebilir; sonunda ortaya küçük bir cache kütüphanesi çıkar. React ekibi de effect ile veri çekmeyi temel bir araç olarak anlatıyor ama gerçek uygulamalarda bir framework'ün veri katmanını ya da cache'li bir istemci kütüphanesini öneriyor. Biz TanStack Query'yi seçtik; SWR (adını stale-while-revalidate cache stratejisinden alan bir veri çekme kütüphanesi) ve Redux Toolkit'in (RTK) veri çekme katmanı RTK Query aynı işi gören yaygın alternatifler, React Router'ın loader'ları da bir sonraki yazıda bu tabloya girecek.
Kurulum: QueryClient ve Tipli Bir API Client'ı
npm install @tanstack/react-query@5.107.1
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from '@/features/auth/AuthProvider'
import { CartProvider } from '@/features/cart/CartProvider'
import { ThemeProvider } from '@/shared/theme/ThemeProvider'
// Bütün uygulama için tek cache; veri 30 saniye boyunca taze sayılır
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000 } },
})
// Provider'lar ağacı root seviyesindeki injector'ler gibi sarar: component'in üstündeki en yakın provider geçerli
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>
<CartProvider>
<App />
</CartProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
QueryClient uygulamanın tek cache'i. TanStack Query'nin varsayılanları dokümanında açıkça yazıyor: cache'teki veri varsayılan olarak hemen "stale" sayılıyor (staleTime: 0), stale veri component yeniden mount olunca, pencere odağı geri gelince ya da ağ yeniden bağlanınca arka planda yenileniyor, kullanılmayan query'ler beş dakika sonra cache'ten atılıyor ve hata veren query'ler üç kez yeniden deneniyor. Katalog için 30 saniyelik bir staleTime yeterince taze.
Fetch çağrılarını tek bir fonksiyonda topladık. Bookstore.Api hata durumlarında RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 9457'ye uygun Problem Details (HTTP API'lerinde hata yanıtları için standart JSON biçimi) döndürüyor; client bunu bir ApiError'a çeviriyor:
// RFC 9457 Problem Details; ASP.NET Core'un AddProblemDetails()'i bu biçimi üretir
export type ProblemDetails = {
type?: string;
title?: string;
status?: number;
detail?: string;
errors?: Record<string, string[]>;
};
export class ApiError extends Error {
readonly status: number;
readonly problem: ProblemDetails | null;
constructor(status: number, problem: ProblemDetails | null) {
super(problem?.detail ?? problem?.title ?? `HTTP ${status}`);
this.name = 'ApiError';
this.status = status;
this.problem = problem;
}
}
// Bookstore.Api'nin nasıl cevap verdiğini bilen tek yer: başarıda JSON, hatada Problem Details (ya da boş gövde)
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}) },
});
if (!response.ok) {
const isProblem = response.headers.get('Content-Type')?.includes('application/problem+json') ?? false;
throw new ApiError(response.status, isProblem ? ((await response.json()) as ProblemDetails) : null);
}
return (await response.json()) as T;
}
Response'u as T ile tiplemek bir varsayım, doğrulama değil; TypeScript serisindeki "TypeScript ile Asenkron Kod: Promise, async ve fetch Cevabını Tiplemek" yazısında bunun neden bir sözleşme olduğunu ve runtime doğrulamanın ne zaman gerektiğini anlattık. Formlar yazısında zod ile bu doğrulamaya geri döneceğiz.
useQuery: Query Key, staleTime ve Sayfalama
import { keepPreviousData, queryOptions } from '@tanstack/react-query';
import type { BookDetails, BookSummary, PagedResult } from '@/features/catalog/types';
import { apiFetch } from '@/shared/api';
export const pageSize = 8;
// İsteğin bağlı olduğu her değer key'in parçası
export function booksQuery(search: string, page: number) {
return queryOptions({
queryKey: ['books', { search, page }],
queryFn: ({ signal }) =>
apiFetch<PagedResult<BookSummary>>(
`/api/books?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`, { signal }),
// Sonraki sayfa yüklenirken önceki sayfayı göstermeye devam et
placeholderData: keepPreviousData,
});
}
export function bookQuery(isbn: string) {
return queryOptions({
queryKey: ['book', isbn],
queryFn: ({ signal }) => apiFetch<BookDetails>(`/api/books/${isbn}`, { signal }),
});
}
queryOptions key'i, fetch fonksiyonunu ve seçenekleri tek bir object'te topluyor; aynı tanım hem useQuery'de hem de cache'e elle dokunurken (prefetch, invalidation) kullanılabiliyor ve TypeScript key ile veri tipini birbirine bağlıyor. Query key, cache'teki girdinin kimliği: ['books', { search: 'Dune', page: 1 }] ile ['books', { search: '', page: 2 }] iki ayrı girdi. Fonksiyon parametre olarak bir signal alıyor; TanStack Query, sonucuna artık kimse bakmayan bir isteği bununla iptal ediyor. Katalog sayfasının yeni hâli:
export function CatalogPage() {
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);
const { quantityOf, add } = useCart();
const searchInput = useRef<HTMLInputElement>(null);
useKeyboardShortcut('/', () => searchInput.current?.focus());
useKeyboardShortcut('Escape', () => setSelectedIsbn(null));
// Elle yazılmış effect'in yerini alır: cache, deduplication, iptal ve retry beraberinde gelir
const search = useDebouncedValue(query.trim(), 300);
const { data, error, isFetching, isPlaceholderData } = useQuery(booksQuery(search, page));
const pageCount = data ? Math.max(1, Math.ceil(data.total / pageSize)) : 1;
function handleQueryChange(value: string) {
setQuery(value);
setPage(1); // yeni bir arama ilk sayfadan başlar
}
return (
<>
<h1>Catalog</h1>
<div className="toolbar">
<SearchBox ref={searchInput} value={query} onChange={handleQueryChange} />
</div>
<p className="muted" role="status">
{error ? `Could not load books (${error.message})`
: !data ? 'Loading…'
: `${data.total} book(s) found${isFetching ? ' · updating…' : ''}`}
</p>
{data && (
<>
<div className={isPlaceholderData ? 'stale' : undefined}>
<BookList books={data.items} quantityOf={quantityOf} onAdd={add} onSelect={setSelectedIsbn} />
</div>
<nav className="pager" aria-label="Pages">
<button type="button" disabled={page === 1} onClick={() => setPage(page - 1)}>Previous</button>
<span>Page {page} of {pageCount}</span>
<button type="button" disabled={page >= pageCount} onClick={() => setPage(page + 1)}>Next</button>
</nav>
</>
)}
Altıncı ve yedinci yazılardaki effect, result state'i ve "bu sonuç hangi query için" kontrolü gitti; yerinde tek bir useQuery çağrısı var. Debounce hâlâ bizim işimiz: TanStack Query isteği ne zaman atacağına karar vermiyor, yalnız verilen key için veriyi yönetiyor. Yedinci yazıdaki useDebouncedValue burada aynen kullanılıyor. API sayfalı çalıştığı için dördüncü yazıdan beri duran sıralama menüsünü kaldırdık: yalnız ekrandaki sekiz kitabı fiyata göre sıralamak yanıltıcı olurdu ve API'de bir sıralama parametresi yok.
Önce Tahmin Et'teki senaryoyu ve ardından sayfalar arasında gidip gelmeyi dev sunucusunda çalıştırdık:
76 ms failed: GET /api/books?search=&page=1&pageSize=8 net::ERR_ABORTED
84 ms http: GET /api/books?search=&page=1&pageSize=8 200
107 ms status: 20 book(s) found | 1984, Anna Karenina, Brave New World, Crime and Punishment, Don Quixote, Dune, Emma, Foundation
-- type "Dune"
628 ms http: GET /api/books?search=Dune&page=1&pageSize=8 200
661 ms status: 1 book(s) found | Dune
-- clear the box, then type "Dune" again
1168 ms status: 20 book(s) found | 1984, Anna Karenina, Brave New World, Crime and Punishment, Don Quixote, Dune, Emma, Foundation
1677 ms status: 1 book(s) found | Dune
-- Next page, then Previous
2276 ms http: GET /api/books?search=&page=2&pageSize=8 200
2536 ms status: 20 book(s) found | Frankenstein, Great Expectations, Jane Eyre, Middlemarch, Moby-Dick, Pride and Prejudice, The Count of Monte Cristo, The Great Gatsby
2866 ms status: 20 book(s) found | 1984, Anna Karenina, Brave New World, Crime and Punishment, Don Quixote, Dune, Emma, Foundation
-- add Dune to the cart
2893 ms http: POST /api/carts 201
2897 ms http: PUT /api/carts/{cartId}/items/9780441172719 200
3252 ms header: Cart: 1 item(s) · ₺489.00
Cevap: tek istek. Kutu boşaltılınca ilk sayfa, tekrar "Dune" yazılınca Dune sonucu cache'ten geldi; ikinci sayfaya geçiş bir istek attı, geri dönüş atmadı. En baştaki iptal edilmiş istek StrictMode'dan: component'in ilk mount'u söküldüğünde o query'nin tek gözlemcisi kayboldu ve TanStack Query signal'ı kullanan isteği kesti; ikinci mount yenisini başlattı. placeholderData: keepPreviousData sayesinde ikinci sayfa yüklenirken ekranda birinci sayfa soluk hâlde kaldı, liste bir anlığına bile boşalmadı.
Angular tarafında aynı işi httpResource görüyor: bir signal'a bağlı isteği o signal değişince yeniden çalıştırıyor, eskisini iptal ediyor ve value, isLoading, error signal'larını veriyor. Ama component'ler arası paylaşılan bir cache, deduplication, retry ve mutation sonrası invalidation gibi kavramlar onun kapsamında değil; Angular'da State Yönetimi serisindeki "Server State'i Yönetmek: resource, httpResource, chain() ve Mutation Sonrası Invalidation" yazısında invalidation'ı elle kurmuştuk. TanStack Query'nin hâlâ deneysel etiketli bir Angular adaptörü de var (@tanstack/angular-query-experimental); iki dünyada aynı kavramlarla çalışmak isteyen ekipler için bir seçenek.
Sunucu Tarafı Sepet: useMutation ve Optimistic Update
Sepetin içeriği artık server state. Tarayıcıda yalnız hangi sepetin bize ait olduğu (cart id) kalıyor; onu yedinci yazıdaki Context'te, localStorage'a yazarak tutuyoruz. Cart API'si ince bir sarmalayıcı:
import type { Cart, Order } from '@/features/cart/types';
import { apiFetch } from '@/shared/api';
export const cartKey = (cartId: string | null) => ['cart', cartId] as const;
export const cartApi = {
create: () => apiFetch<Cart>('/api/carts', { method: 'POST' }),
get: (cartId: string, signal?: AbortSignal) => apiFetch<Cart>(`/api/carts/${cartId}`, { signal }),
setQuantity: (cartId: string, isbn: string, quantity: number) =>
apiFetch<Cart>(`/api/carts/${cartId}/items/${isbn}`, { method: 'PUT', body: JSON.stringify({ quantity }) }),
remove: (cartId: string, isbn: string) => apiFetch<Cart>(`/api/carts/${cartId}/items/${isbn}`, { method: 'DELETE' }),
placeOrder: (cartId: string, customerName: string, customerEmail: string) =>
apiFetch<Order>('/api/orders', { method: 'POST', body: JSON.stringify({ cartId, customerName, customerEmail }) }),
};
Asıl iş useCart'ta:
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query';
import { cartApi, cartKey } from '@/features/cart/cartApi';
import { useCartId } from '@/features/cart/CartContext';
import { applyChange, type QuantityChange } from '@/features/cart/cartLines';
import type { Cart, CartLine } from '@/features/cart/types';
import { ApiError } from '@/shared/api';
const quantityMutationKey = ['cart', 'setQuantity'];
export function useCart() {
const { cartId, setCartId } = useCartId();
const queryClient = useQueryClient();
const { data: cart = null } = useQuery({
queryKey: cartKey(cartId),
queryFn: async ({ signal }) => {
try {
return await cartApi.get(cartId!, signal);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
return null; // sunucu bu sepeti artık tanımıyor (ör. siparişe dönüştü)
}
throw error;
}
},
enabled: cartId !== null,
});
const quantityMutation = useMutation({
mutationKey: quantityMutationKey,
mutationFn: async (change: QuantityChange) => {
let id = cartId;
if (id === null || cart === null) {
const created = await cartApi.create();
// Yeni (boş) sepeti cache'e yaz; key değişince GET atılmasın
queryClient.setQueryData(cartKey(created.id), created);
setCartId(created.id);
id = created.id;
}
return change.quantity > 0
? cartApi.setQuantity(id, change.isbn, change.quantity)
: cartApi.remove(id, change.isbn);
},
// Optimistic update: cache'teki sepeti hemen değiştir, geri almak için eskisini sakla
onMutate: async (change) => {
if (cartId === null || cart === null) {
return { previous: undefined };
}
await queryClient.cancelQueries({ queryKey: cartKey(cartId) });
const previous = queryClient.getQueryData<Cart | null>(cartKey(cartId)) ?? undefined;
if (previous) {
queryClient.setQueryData(cartKey(cartId), applyChange(previous, change));
}
return { previous };
},
onError: (_error, _change, result) => {
if (result?.previous) {
queryClient.setQueryData(cartKey(result.previous.id), result.previous);
}
},
onSuccess: (saved) => queryClient.setQueryData(cartKey(saved.id), saved),
});
// Mutation state'i mutate()'i çağıran hook instance'ına ait; bunun yerine en sonuncuyu cache'ten oku
const latest = useMutationState({ filters: { mutationKey: quantityMutationKey }, select: (m) => m.state }).at(-1);
const lines = cart?.items ?? [];
const quantityOf = (isbn: string) => lines.find((line) => line.isbn === isbn)?.quantity ?? 0;
const setQuantity = (line: Omit<CartLine, 'lineTotal'>) => quantityMutation.mutate(line);
return {
cartId: cart?.id ?? null,
lines,
itemCount: lines.reduce((sum, line) => sum + line.quantity, 0),
total: cart?.total ?? 0,
quantityOf,
error: latest?.status === 'error' ? latest.error : null,
add: (book: { isbn: string; title: string; price: number }, amount: number) =>
setQuantity({ isbn: book.isbn, title: book.title, unitPrice: book.price, quantity: quantityOf(book.isbn) + amount }),
setQuantity,
remove: (line: CartLine) => setQuantity({ ...line, quantity: 0 }),
restore: setQuantity,
};
}
Okuma tarafı bir useQuery. Header, katalog ve sepet paneli useCart'ı ayrı ayrı çağırıyor ama aynı key'i kullandıkları için tek bir istek gidiyor ve üçü aynı cache girdisini okuyor. Yazma tarafı bir useMutation. İlk "Add"de sepet yoksa önce POST /api/carts ile oluşturuluyor; yeni boş sepeti hemen cache'e yazmamızın sebebi, cart id değişince useQuery'nin yeni key için gereksiz bir GET atmaması. İlk denemede bu satır yoktu ve network kaydında POST ile PUT arasında boş sepeti getiren bir GET vardı.
Optimistic update üç adımlık: onMutate o key için uçuşta olan okumaları iptal ediyor (yoksa geç gelen eski yanıt bizim değişikliğimizi ezebilir), mevcut sepeti saklıyor ve cache'i beklenen yeni hâliyle güncelliyor. onError saklanan sepeti geri yazıyor; onSuccess ise sunucunun döndürdüğü gerçek sepeti cache'e koyuyor. Beklenen hâli hesaplayan fonksiyon React'ten bağımsız, saf bir fonksiyon:
import type { Cart, CartLine } from '@/features/cart/types';
export type QuantityChange = { isbn: string; title: string; unitPrice: number; quantity: number };
// Saf: bir adet değişikliğinden sonra sepetin nasıl görüneceğine dair yerel tahminimiz (sunucunun cevabı yerini alır)
export function applyChange(cart: Cart, change: QuantityChange): Cart {
const others = cart.items.filter((line) => line.isbn !== change.isbn);
const items: CartLine[] = change.quantity > 0
? [...others, { ...change, lineTotal: change.unitPrice * change.quantity }].toSorted((a, b) => a.title.localeCompare(b.title))
: others;
return { ...cart, items, total: items.reduce((sum, line) => sum + line.lineTotal, 0) };
}
Playwright ile her PUT'u 1,5 saniye geciktirdik ve üçüncüsünü 500 ile düşürdük:
148 ms http: POST /api/carts 201
1656 ms http: PUT /api/carts/{cartId}/items/9780441172719 200
1928 ms saved: Cart: 1 item(s) · ₺489.00
-- add Emma (PUT takes 1.5 s)
2009 ms at once: Cart: 2 item(s) · ₺738.90
3463 ms http: PUT /api/carts/{cartId}/items/9780141439587 200
3711 ms saved: Cart: 2 item(s) · ₺738.90
-- add Foundation, the server answers 500
3783 ms at once: Cart: 3 item(s) · ₺1,057.90
5234 ms http: PUT /api/carts/{cartId}/items/9780553293357 500
5486 ms after: Cart: 2 item(s) · ₺738.90
5487 ms alerts: ["Cart not updated: An error occurred while processing your request."]
Emma'yı ekleyince header 50 ms içinde değişti, sunucunun cevabı 1,5 saniye sonra geldi ve ekranda bir şey değişmedi, çünkü tahminimiz doğruydu. Foundation'da header önce 3 ürün gösterdi, 500 gelince 2'ye geri döndü ve sepet panelinde hata mesajı çıktı. Bizim sürümümüz ilk eklemede iyimser davranmıyor: sepet henüz yokken önce oluşturulması gerekiyor ve kullanıcı ilk eklemede gecikmeyi görüyor.
Hata mesajı ilk denemede hiç görünmedi:
162 ms http: POST /api/carts 201
1669 ms http: PUT /api/carts/{cartId}/items/9780441172719 200
1941 ms saved: Cart: 1 item(s) · ₺489.00
-- add Emma (PUT takes 1.5 s)
2016 ms at once: Cart: 2 item(s) · ₺738.90
3469 ms http: PUT /api/carts/{cartId}/items/9780141439587 200
3720 ms saved: Cart: 2 item(s) · ₺738.90
-- add Foundation, the server answers 500
3778 ms at once: Cart: 3 item(s) · ₺1,057.90
5230 ms http: PUT /api/carts/{cartId}/items/9780553293357 500
5480 ms after: Cart: 2 item(s) · ₺738.90
5484 ms alerts: []
Sepet geri alındı ama alert yoktu. Sebep, yedinci yazıda gördüğümüz "hook mantık paylaşır, state paylaşmaz" kuralının TanStack Query'deki karşılığı: useMutation'ın error'ı o hook'u çağıran instance'a ait. Butona basılan yer kitap kartını render eden CatalogPage'in useCart'ı, hatayı gösteren ise CartPanel'in useCart'ı; ikisi ayrı mutation instance'ları. Çözüm, mutation'a bir mutationKey verip son durumu useMutationState ile cache'ten okumak.
Problem Details Hatalarını Göstermek
Sipariş de bir mutation. Başarılı olunca sunucu sepeti siliyor ve stok ayırıyor; bu yüzden cart id'yi unutuyor, sepet query'lerini cache'ten atıyor ve kitap detaylarını invalidate ediyoruz:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { cartApi } from '@/features/cart/cartApi';
import { useCartId } from '@/features/cart/CartContext';
export function usePlaceOrder() {
const { setCartId } = useCartId();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { cartId: string; name: string; email: string }) =>
cartApi.placeOrder(input.cartId, input.name, input.email),
onSuccess: () => {
// Sunucu sepeti sildi ve stok ayırdı: sepeti unut, stok sayılarını yeniden çek
setCartId(null);
queryClient.removeQueries({ queryKey: ['cart'] });
queryClient.invalidateQueries({ queryKey: ['book'] });
},
});
}
Yerel API kopyasında War and Peace'in stoğu 3. Ada olarak giriş yapıp sepete 5 tane koyduk ve sipariş verdik, sonra adedi 3'e indirip tekrar denedik:
http: GET /api/books/9781400079988 200 application/json
screen: 3 in stock
-- place order with 5 × War and Peace (3 in stock)
http: POST /api/orders 409 application/problem+json
body: {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"Not enough stock for: 9781400079988.","traceId":"00-f5db24403da0a710cd58e7671123310e-2ef433914a5a486c-00"}
screen: Order failed: Not enough stock for: 9781400079988.
-- change the quantity to 3 and try again
http: POST /api/orders 201 application/json
screen: Order #1 placed · ₺2,067.00 | Cart: 0 item(s) · ₺0.00
-- open War and Peace again
http: GET /api/books/9781400079988 200 application/json
screen: Out of stock
409 yanıtı application/problem+json ile geldi; ApiError mesajını detail alanından aldığı için kullanıcı sunucunun cümlesini gördü. İkinci denemede sipariş oluştu ve sepet sıfırlandı. Detay paneli siparişten önce açılmış ve "3 in stock" olarak cache'lenmişti; staleTime 30 saniye olduğu için normalde yeniden istenmeyecekti. invalidateQueries onu stale yaptı ve panel tekrar açılınca yeni bir istek gitti: "Out of stock". Mutation sonrasında hangi query'lerin bayatladığını söylemek senin sorumluluğun; TanStack Query bunu tahmin etmez.
Ekranda ISBN (uluslararası standart kitap numarası) yerine kitap adını göstermek ya da 409'u bir form alanının yanına yazmak daha iyi bir deneyim olurdu. Problem Details'ın errors alanını form alanlarına eşlemeyi onuncu yazıda, sipariş formunu kurarken yapacağız.
Suspense, Error Boundary ve use()
Detay paneli useQuery yerine useSuspenseQuery kullanıyor. Fark şu: veri yoksa component render edilmiyor, "suspend" ediyor; en yakın <Suspense> fallback'ini gösteriyor. Hata olursa en yakın error boundary yakalıyor. Böylece panelin kendisi yalnız verinin geldiği durumu yazıyor:
export function BookDetailsPanel({ isbn, onClose }: BookDetailsPanelProps) {
// Detaylar cache'e gelene kadar suspend eder; hatalar en yakın error boundary'ye gider
const { data: book } = useSuspenseQuery(bookQuery(isbn));
// Tek bir kitaba ait; parent bunu bir key ile sıfırlar
{selectedIsbn && (
// key: başka bir kitap yeni bir boundary, yeni bir panel ve boş bir hediye notu alır
<ErrorBoundary key={selectedIsbn} fallback={(e) => <p className="error" role="alert">Could not load details ({e.message})</p>}>
<Suspense fallback={<p className="muted">Loading details…</p>}>
<BookDetailsPanel isbn={selectedIsbn} onClose={() => setSelectedIsbn(null)} />
</Suspense>
</ErrorBoundary>
)}
Error boundary React'te hâlâ bir class component; getDerivedStateFromError'ın hook karşılığı yok:
import { Component, type ErrorInfo, type ReactNode } from 'react';
type ErrorBoundaryProps = {
fallback: (error: Error) => ReactNode;
children: ReactNode;
};
type ErrorBoundaryState = { error: Error | null };
// Error boundary'ler hâlâ class component: getDerivedStateFromError'ın hook karşılığı yok
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[boundary]', error.message, info.componentStack?.trim().split('\n')[0]);
}
render() {
return this.state.error ? this.props.fallback(this.state.error) : this.props.children;
}
}
Emma'nın detay isteğini Playwright ile her seferinde 500'e çevirdik:
-- click "Dune"
13 ms http: GET /api/books/9780441172719 200
4 ms screen: Loading details…
306 ms screen: Dune | 25 in stock
-- click "Emma" (its details answer 500)
52 ms http: GET /api/books/9780141439587 500
1056 ms http: GET /api/books/9780141439587 500
3060 ms http: GET /api/books/9780141439587 500
7062 ms http: GET /api/books/9780141439587 500
7065 ms console: [boundary] An error occurred while processing your request. at BookDetailsPanel (http://127.0.0.1:52142/src/features/catalog/BookDetailsPanel.tsx?t=1794707205566:21:36)
7350 ms screen: Could not load details (An error occurred while processing your request.)
-- click "Dune" again
20 ms screen: Dune
Dune'da istek 13 ms'de döndü ama panel 306 ms'de göründü. Bu bir yavaşlık değil: react.dev'e göre React, askıya alınmış içeriği en fazla 300 ms'de bir ortaya çıkarıyor, böylece aynı aralıkta hazır olan sınırları tek seferde gösteriyor. Emma'da ise hata ekrana 7 saniye sonra düştü: TanStack Query'nin varsayılan üç retry'ı 1, 2 ve 4 saniyelik bekleyişlerle dört istek attı. Bir detay paneli için bu çok uzun; üretimde retry'ı query bazında azaltmak ya da 4xx hatalarında hiç denememek mantıklı. Error boundary'deki key sayesinde Dune'a tekrar tıklayınca yeni bir boundary oluştu ve Dune cache'ten anında geldi. Sayfadaki diğer her şey hata boyunca çalışmaya devam etti; yedinci yazıda boundary olmadığı için bütün ağaç düşmüştü.
React 19'un use(promise)'u aynı Suspense mekanizmasının alt seviye API'si: bir promise'i okuyor, bekliyorsa suspend ediyor. Cazip görünen kullanım, promise'i render içinde oluşturmak:
export function BookDetailsPanel({ isbn, onClose }: BookDetailsPanelProps) {
// YANLIŞ: her render'da yeni bir promise
const book = use(apiFetch<BookDetails>(`/api/books/${isbn}`));
requests for /api/books/{isbn} in 3 s: 3739
panel on screen: false
Üç saniyede 3.739 istek gitti ve panel hiç açılmadı. Component suspend ediyor, promise çözülünce React onu yeniden render ediyor, render yeni bir fetch ve yeni bir promise üretiyor, component yeniden suspend ediyor. react.dev de bunu açıkça söylüyor: use'a verilen promise render'lar arasında aynı instance olarak kalmalı, yani ya bir cache'ten gelmeli ya da render dışında oluşturulmalı. Promise'i tıklama handler'ında oluşturup props ile veren sürüm:
const [details, setDetails] = useState<Promise<BookDetails> | null>(null);
function handleSelect(isbn: string) {
setSelectedIsbn(isbn);
setDetails(apiFetch<BookDetails>(`/api/books/${isbn}`)); // render sırasında değil, bir event'te oluşturuldu
}
// Promise bir kez, tıklama handler'ında oluşturuldu; use() yalnız okur
const book = use(details);
requests for /api/books/{isbn} in 3 s: 1
panel on screen: true
Çalışıyor, ama cache yok, iptal yok, retry yok; bunları istiyorsan yine bir kütüphaneye dönüyorsun. use(promise)'un asıl yeri, promise'i bir framework'ün ya da kütüphanenin verdiği durumlar: Server Component'ten client'a geçen promise'ler (on beşinci yazı), router loader'ları ve Suspense destekli veri kütüphaneleri.
Üretimde Dikkat
staleTime bir ürün kararı. Katalog için 30 saniye makul; stok ve fiyat gibi alanlar için daha kısa, kategori listesi gibi neredeyse hiç değişmeyen veri için çok daha uzun olabilir. Varsayılan 0 değeri her mount'ta ve her pencere odağında arka planda bir istek demek; network sekmesinde açıklanamayan istekler görüyorsan önce buna bak.
Retry'ı hataya göre ayır. Ağ kopması ve 503 için retry anlamlı; 404 ve 409 için değil, kullanıcıyı yalnız bekletir. retry seçeneği bir fonksiyon da alabiliyor; ApiError'ın status'una bakıp karar vermek bunun için.
Optimistic update'i geri alınabilir ve tahmin edilebilir işlemlerde kullan. Sepete ekleme iyi bir aday: kullanıcı anında geri bildirim bekliyor ve hata nadir. Sipariş vermek kötü bir aday: stok, ödeme ve doğrulama sunucuda; "siparişiniz alındı" deyip birkaç saniye sonra geri almak kullanıcıyı yanıltır. Biz siparişte bilerek isPending ile butonu kilitledik.
TanStack Query'nin Devtools paketi (@tanstack/react-query-devtools) cache'teki key'leri, durumlarını ve ne zaman stale olacaklarını gösteriyor; geliştirmede açık tutmak query key hatalarını çok erken yakalatır.
Sık Düşülen Tuzaklar
Query key'e parametreyi koymamak. Key'den search ve page'i çıkaran bir sürümle "Dune" yazdık:
return queryOptions({
queryKey: ['books'], // YANLIŞ: search ve page key'de yok
request: ?search=&page=1&pageSize=8
request: ?search=&page=1&pageSize=8
input: Dune | status: 20 book(s) found | first books: 1984, Anna Karenina, Brave New World
Kutuda "Dune" yazıyor, ekranda 20 kitap var ve yeni istek hiç gitmedi: key değişmediği ve veri taze olduğu için TanStack Query'nin yapacak bir şeyi yok. Kural, effect'teki dependency array'in aynısı: fetch fonksiyonunun kullandığı her değer key'de olmalı. TanStack'in ESLint plugin'indeki exhaustive-deps kuralı bunu denetliyor; Oxlint'te karşılığı yok.
Server state'i başka bir state'e kopyalamak. useEffect(() => setBooks(data), [data]) ya da sorgu sonucunu Context'e veya global bir store'a yazmak, cache'in yanında ikinci bir kopya yaratır; invalidation birini günceller, diğeri eski kalır. Veriye ihtiyacı olan her component aynı query'yi çağırsın; istek zaten tek gider.
Mutation durumunu yanlış instance'ta aramak: yukarıda gördüğümüz gibi isPending ve error mutate'i çağıran hook'a ait. Başka bir yerde göstereceksen mutationKey ve useMutationState kullan.
use()'a render içinde oluşturulmuş bir promise vermek: her render yeni bir istek demek. Promise ya cache'ten gelir ya da render dışında oluşturulur.
Küçük Bir Deneme
★☆☆ Detay query'si için retry'ı, ApiError'ın status'u 400–499 arasındaysa hiç denemeyecek, diğer hatalarda bir kez deneyecek şekilde ayarla. Emma senaryosunda hata kaç saniyede görünüyor?
★★☆ Kullanıcı bir kitap kartının üzerine geldiğinde (onMouseEnter) o kitabın detayını queryClient.prefetchQuery(bookQuery(isbn)) ile önceden çek. Tıklayınca "Loading details…" fallback'i hâlâ görünüyor mu? Network kaydında ne değişti?
★★★ Sepet paneline "Clear cart" butonunu geri getir: her satır için bir DELETE atan ve hepsini tek bir optimistic update olarak gösteren bir mutation yaz. İsteklerden biri hata verirse sepet hangi hâle dönmeli? Sunucunun döndürdüğü son sepetle cache'i nasıl tutarlı hâle getirirsin?
Veri çekmeyi cache'li bir katmana taşıdık; katalog, detay ve sepet aynı sayfada duruyor. Sıradaki yazıda React Router 8 ile bunları ayrı URL'lere bölecek, nested layout'ları, URL parametrelerini, loader ile veriyi route'a girmeden önce yüklemeyi ve lazy route ile code splitting'i göreceğiz.