React Router 8 ile Routing: Route'lar, Nested Layout, URL Parametreleri, Loader/Action ve Lazy Route
React Router 8'in data mode'unda route'lar createBrowserRouter'a object olarak verilir; layout route'u ve Outlet ile iç içe sayfalar, loader ile route'a girmeden önce veri, middleware ile guard, action ile form ve lazy ile code splitting elde edilir. Bookstore'u URL'lere bölüp aramayı query string'e taşıyor, aynı route'ta key'siz state'in sonraki kitaba taşındığını, lazy chunk'ın middleware yönlendirmesinden önce indiğini ve static sunucuda SPA fallback'in neden şart olduğunu gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler React 19.3, React Router 8.5, TanStack Query 5.107, TypeScript 7.0, Vite 8.3, Oxlint 1.93 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ış, gezinme 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 istemcisi şimdiye kadar tek bir sayfaydı: katalog, detay paneli ve sepet aynı ekranda duruyordu, adres çubuğu hiç değişmiyordu. Bu yazıda her ekran kendi URL'ine geçiyor: /, /books/:isbn, /cart, /checkout, /orders/:orderId.
Kitap sayfasının altında aynı yazarın diğer kitaplarına link var. Kullanıcı Emma sayfasında hediye notu alanına "For Grace" yazıyor ve "Pride and Prejudice" linkine tıklıyor. URL değişiyor, başlık değişiyor; hediye notu alanında ne yazar? İkinci soru: giriş yapmamış bir kullanıcı /checkout'a gittiğinde ödeme sayfasının JavaScript dosyası indirilir mi?
Üç Mod ve Neden Data Mode
React Router 8 aynı paketi üç şekilde kullanmana izin veriyor. Declarative mode, <BrowserRouter> ve <Routes> ile yalnız URL eşleştirme ve gezinme veriyor. Data mode, route'ları createBrowserRouter'a object olarak veriyor ve loader, action, middleware, pending state gibi veri özelliklerini açıyor. Framework mode ise Vite plugin'i, dosya tabanlı route'lar, SSR (sunucu tarafında render) ve otomatik code splitting ile Remix'in devamı olan tam bir framework. react.dev yeni projelere bir framework öneriyor; biz seri boyunca bir SPA (single-page application; tek HTML sayfasında çalışan uygulama) olarak kalıp Angular Router ile birebir kıyas yapabilmek için data mode'u seçtik. Framework mode'a ve Next.js'e on beşinci yazıda bakacağız.
Sekizinci sürüm, Haziran 2026'da çıkan ve React Router'ın yıllık major sürüm takvimindeki ilk sürüm. Bizi doğrudan ilgilendiren değişiklikler üç tane: react-router-dom paketi kaldırıldı (her şey react-router'dan, DOM'a (tarayıcının sayfayı tuttuğu node ağacı) özel RouterProvider react-router/dom'dan geliyor), paket yalnız ESM (ECMAScript module; tarayıcının ve Node'un yerleşik module sistemi) olarak yayımlanıyor ve v7'de future.v8_middleware flag'i arkasındaki middleware artık her zaman açık. Minimum gereksinimler React 19.2.7 ve Node 22.22.
npm install react-router@8.5.1
Route Ağacı ve Nested Layout
import { createBrowserRouter } from 'react-router';
import { NotFoundPage } from '@/app/NotFoundPage';
import { RootLayout } from '@/app/RootLayout';
import { RouteErrorPage } from '@/app/RouteErrorPage';
import { requireUser } from '@/features/auth/requireUser';
import { SignInPage } from '@/features/auth/SignInPage';
import { CartPanel } from '@/features/cart/CartPanel';
import { bookLoader } from '@/features/catalog/bookLoader';
import { BookPage } from '@/features/catalog/BookPage';
import { CatalogPage } from '@/features/catalog/CatalogPage';
import { orderLoader } from '@/features/orders/orderLoader';
import { OrderPage } from '@/features/orders/OrderPage';
export const router = createBrowserRouter([
{
path: '/',
Component: RootLayout,
ErrorBoundary: RouteErrorPage,
children: [
{ index: true, Component: CatalogPage },
{ path: 'books/:isbn', loader: bookLoader, Component: BookPage },
{ path: 'cart', Component: CartPanel },
{
path: 'checkout',
middleware: [requireUser],
// Code splitting: checkout chunk'ı /checkout'a ilk gidişte indirilir
lazy: async () => {
const [{ CheckoutPage }, { checkoutAction }] = await Promise.all([
import('@/features/checkout/CheckoutPage'),
import('@/features/checkout/checkoutAction'),
]);
return { Component: CheckoutPage, action: checkoutAction };
},
},
{ path: 'orders/:orderId', loader: orderLoader, Component: OrderPage },
{ path: 'sign-in', Component: SignInPage },
{ path: '*', Component: NotFoundPage },
],
},
]);
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router/dom'
import './index.css'
import { router } from '@/app/router'
import { queryClient } from '@/shared/queryClient'
import { ThemeProvider } from '@/shared/theme/ThemeProvider'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
Angular'daki provideRouter(routes) ile aynı fikir: route'lar bir array, her biri bir path ve bir component. App.tsx artık yok; uygulamanın kökü router. Yedinci yazıdaki Layout component'i de yerini bir layout route'una bıraktı:
import { NavLink, Outlet, useNavigation } from 'react-router';
import { UserMenu } from '@/features/auth/UserMenu';
import { CartSummary } from '@/features/cart/CartSummary';
import { ThemeToggle } from '@/shared/theme/ThemeToggle';
// Layout route'u: header kalır, <Outlet /> eşleşen alt route ile değişir (<router-outlet> gibi)
export function RootLayout() {
const navigation = useNavigation();
return (
<>
<header className="site-header">
<strong>Bookstore</strong>
<nav className="main-nav">
<NavLink to="/" end>Catalog</NavLink>
<NavLink to="/cart">Cart</NavLink>
</nav>
<CartSummary />
<ThemeToggle />
<UserMenu />
</header>
{navigation.state !== 'idle' && <p className="progress" role="status">Loading page…</p>}
<main>
<Outlet />
</main>
</>
);
}
<Outlet />, <router-outlet>'in karşılığı: URL hangi çocuk route ile eşleşirse o, buraya render edilir. Header her sayfada aynı kalıyor ve gezinmede yeniden mount edilmiyor; yalnız Outlet'in içi değişiyor. NavLink, aktif route'ta active class'ını ekliyor (routerLinkActive); kök link için end gerekiyor, yoksa / her URL'in öneki olduğu için hep aktif görünür.

Bütün akışı Playwright ile gezdik: arama, kitap sayfası, aynı yazarın başka kitabı, iki kez geri tuşu, sepet, giriş yapmadan ödeme, sipariş ve iki hatalı URL:
open / / | h1: Catalog
type "Austen" /?q=Austen | h1: Catalog
click "Emma" /books/9780141439587 | h1: Emma
click "Pride and Prejudice" /books/9780141439518 | h1: Pride and Prejudice
gift note: ""
browser back /books/9780141439587 | h1: Emma
browser back /?q=Austen | h1: Catalog
search box: "Austen"
click "Cart" /cart | h1: Cart
chunk: /src/features/checkout/CheckoutPage.tsx
chunk: /src/features/checkout/checkoutAction.ts
click "Checkout" /sign-in?redirectTo=/checkout | h1: Sign in
click "Sign in as Ada" /checkout | h1: Checkout
click "Place order" /orders/1 | h1: Order #1
open /books/9780000000000 /books/9780000000000 | h1: 404 Not Found
text: No book with ISBN 9780000000000
open /wishlist /wishlist | h1: Page not found
URL Parametreleri ve URL'deki State
Kitap sayfası ISBN'i (uluslararası standart kitap numarası) useParams() ile okuyor; ActivatedRoute.paramMap'in karşılığı. Arama metni ve sayfa numarası ise artık bir useState değil, URL'in query string'i:
export function CatalogPage() {
// Arama metni ve sayfa URL'de: /?q=Dune&page=2 yer imine eklenebilir ve paylaşılabilir
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';
const page = Number(searchParams.get('page') ?? '1');
const { quantityOf, add } = useCart();
const searchInput = useRef<HTMLInputElement>(null);
useKeyboardShortcut('/', () => searchInput.current?.focus());
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) {
// replace: yazarken her harf geçmişe ayrı kayıt eklemesin; yeni arama 1. sayfadan başlar
setSearchParams(value ? { q: value } : {}, { replace: true });
}
function goToPage(next: number) {
setSearchParams((params) => {
params.set('page', String(next));
return params;
});
}
Bunun kazancı yukarıdaki çıktıda görülüyor: kitap sayfasından iki kez geri dönünce URL /?q=Austen oldu ve arama kutusunda "Austen" yazıyordu. State useState'te olsaydı katalog unmount olduğu anda kaybolurdu. Kullanıcı aramanın linkini birine gönderebiliyor, sayfayı yenileyince aynı sonuçları görüyor. replace: true ise her harfin geçmişe ayrı bir kayıt eklemesini engelliyor; geri tuşu "Auste", "Aust" diye harf harf geri gitmiyor. Hangi state'in URL'de yaşaması gerektiği on birinci yazıdaki karar tablosunun ilk sorusu olacak.
Önce Tahmin Et'teki ilk sorunun cevabı da URL parametreleriyle ilgili. Emma'dan Pride and Prejudice'e geçiş aynı route içinde, yalnız :isbn değişti. React için aynı yerde aynı component tipi duruyor, bu yüzden component'i yeniden kullanıyor ve state'ini koruyor. key olmadan:
export function BookPage() {
const { isbn } = useParams();
const { data: book } = useSuspenseQuery(bookQuery(isbn!));
// YANLIŞ: key yok, aynı BookDetailsView instance'ı sonraki kitap için de kullanılır
return <BookDetailsView book={book} />;
}
on Emma: gift note = "For Grace"
on Pride and Prejudice: gift note = "For Grace"
Emma için yazılan not Pride and Prejudice'e taşındı. Angular'da aynı route'ta parametre değişince component'in yeniden oluşturulmaması da aynı soruna yol açar; orada paramMap'e abone olup alanları elle sıfırlıyorduk. React'te çözüm beşinci yazıdan tanıdık: snapshot'taki BookPage, detay görünümünü key={book.isbn} ile render ediyor ve yukarıdaki gezinme çıktısında not boş geliyor.
Loader: Route'a Girmeden Önce Veri
import { data, type LoaderFunctionArgs } from 'react-router';
import { bookQuery } from '@/features/catalog/catalogApi';
import { ApiError } from '@/shared/api';
import { queryClient } from '@/shared/queryClient';
// Resolver gibi: route ancak kitap cache'e girdikten sonra render edilir
export async function bookLoader({ params }: LoaderFunctionArgs) {
try {
await queryClient.ensureQueryData(bookQuery(params.isbn!));
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
throw data(`No book with ISBN ${params.isbn}`, { status: 404, statusText: 'Not Found' });
}
throw error;
}
return null;
}
export function BookPage() {
const { isbn } = useParams();
const { data: book } = useSuspenseQuery(bookQuery(isbn!));
// Aynı route, yeni :isbn → React bu component'i korur; key her kitaba kendi form state'ini verir
return <BookDetailsView key={book.isbn} book={book} />;
}
Loader Angular'daki resolver'ın karşılığı: router, eşleşen route'ların loader'larını paralel çalıştırıyor ve hepsi bitmeden yeni sayfayı render etmiyor. Loader'ın kendisi veri döndürmüyor; veriyi sekizinci yazıdaki TanStack Query cache'ine koyuyor, component de aynı query'yi useSuspenseQuery ile okuyor. Bu ikili, React Router ile TanStack Query'yi birlikte kullanırken yaygın bir yol: loader "ne zaman" sorusunu (gezinmeden önce) cevaplıyor, cache "nerede ve ne kadar süre" sorusunu. Veri cache'te varsa, bayat olsa bile, ensureQueryData istek atmıyor ve loader anında bitiyor; bayat veriyi component'teki useSuspenseQuery arka planda yeniliyor.
Kitap detay isteğini Playwright ile 1,2 saniye geciktirip katalogdan Dune'a tıkladık, sonra kataloğa dönüp tekrar tıkladık:
138 ms / | h1: Catalog | progress: Loading page…
743 ms / | h1: Catalog | progress: Loading page…
1527 ms /books/9780441172719 | h1: Dune | progress: -
-- second visit (cached)
1618 ms /books/9780441172719 | h1: Dune | progress: -
Loader çalışırken URL hâlâ / ve ekranda hâlâ katalog vardı; useNavigation().state "loading" olduğu için layout'taki "Loading page…" şeridi görünüyordu. Veri gelince URL ve sayfa birlikte değişti. İkinci ziyarette veri cache'te taze olduğu için gecikme hiç yaşanmadı. Bu, sekizinci yazıdaki Suspense fallback'inden farklı bir deneyim: orada yeni ekran hemen açılıp içinde "Loading details…" görünüyordu, burada eski ekran yeni ekran hazır olana kadar kalıyor. İkisi de geçerli; uzun süren loader'larda kullanıcı tıklamasının işe yaradığını görmeli, bu yüzden bir pending göstergesi şart.
Bulunamayan bir ISBN'de loader bir 404 response'u fırlatıyor ve kök route'un ErrorBoundary'si onu yakalıyor:
import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
// Kök route'un hata ekranı: loader hataları, fırlatılan response'lar ve render hataları buraya düşer
export function RouteErrorPage() {
const error = useRouteError();
const title = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : 'Something went wrong';
const detail = isRouteErrorResponse(error) ? String(error.data) : error instanceof Error ? error.message : '';
return (
<main>
<h1>{title}</h1>
<p>{detail}</p>
<p><Link to="/">Back to the catalog</Link></p>
</main>
);
}
Tanımsız URL'ler ise path: '*' ile NotFoundPage'e düşüyor; ikisi de çıktının son satırlarında. Fark, 404'ün kaynağı: /wishlist diye bir route yok, /books/9780000000000 ise geçerli bir route ama böyle bir kitap yok.
Middleware ile Auth Kontrolü ve Action ile Form
Ödeme sayfası giriş yapmış bir kullanıcı istiyor. Angular'da bunu bir CanActivate guard'ı ile yapardık; React Router 8'de karşılığı middleware:
import { createContext, redirect, type MiddlewareFunction } from 'react-router';
import { authStore, type User } from '@/features/auth/authStore';
// Bu middleware'in altındaki loader ve action'ların okuyabileceği tipli bir alan
export const userContext = createContext<User>();
// Route'a her gezinmede loader/action'dan önce çalışır: CanActivate guard'ının karşılığı
export const requireUser: MiddlewareFunction = ({ request, context }) => {
const user = authStore.get();
if (user === null) {
const { pathname } = new URL(request.url);
throw redirect(`/sign-in?redirectTo=${encodeURIComponent(pathname)}`);
}
context.set(userContext, user);
};
Middleware router'ın içinde, React ağacının dışında çalışıyor; useAuth gibi bir hook çağıramaz, bir Context'i okuyamaz. Bu yüzden yedinci yazıdaki AuthProvider'ı küçük bir dış store'a çevirdik ve component'ler onu yine yedinci yazıda gördüğümüz useSyncExternalStore ile okuyor:
// React dışında tutulan demo giriş: component'ler useSyncExternalStore ile, router middleware'i get() ile okur
export type User = { name: string; email: string };
let user: User | null = null;
const listeners = new Set<() => void>();
export const authStore = {
get: () => user,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
signIn(next: User) {
user = next;
listeners.forEach((l) => l());
},
signOut() {
user = null;
listeners.forEach((l) => l());
},
};
Aynı sebeple sepet id'si de bir Context'ten cartIdStore adlı benzer bir module'e taşındı: sipariş action'ı da router'da çalışıyor ve hangi sepetin sipariş edileceğini bilmek zorunda. React dışından da okunması gereken state, React'in içinde yaşayamaz; bu ayrım on birinci yazıdaki Zustand store'larının da çıkış noktası.
Middleware kullanıcıyı context.set(userContext, user) ile router'ın context'ine koyuyor; bu React'in Context'i değil, router'ın loader ve action'lara değer taşıdığı tipli bir kutu. Sipariş action'ı kullanıcıyı oradan okuyor:
import { redirect, type ActionFunctionArgs } from 'react-router';
import { userContext } from '@/features/auth/requireUser';
import { cartApi } from '@/features/cart/cartApi';
import { cartIdStore } from '@/features/cart/cartIdStore';
import { ApiError } from '@/shared/api';
import { queryClient } from '@/shared/queryClient';
// <Form method="post"> gönderilince çalışır; requireUser kullanıcıyı context'e çoktan koymuştur
export async function checkoutAction({ context }: ActionFunctionArgs) {
const user = context.get(userContext);
const cartId = cartIdStore.get();
if (cartId === null) {
return { error: 'Your cart is empty.' };
}
try {
const order = await cartApi.placeOrder(cartId, user.name, user.email);
cartIdStore.set(null);
queryClient.removeQueries({ queryKey: ['cart'] });
queryClient.invalidateQueries({ queryKey: ['book'] });
return redirect(`/orders/${order.id}`);
} catch (error) {
if (error instanceof ApiError) {
return { error: error.message };
}
throw error;
}
}
import { Form, useActionData, useNavigation } from 'react-router';
import { useCart } from '@/features/cart/useCart';
import type { checkoutAction } from '@/features/checkout/checkoutAction';
import { formatPrice } from '@/shared/format';
export function CheckoutPage() {
const { lines, total } = useCart();
const result = useActionData<typeof checkoutAction>();
const navigation = useNavigation();
const placing = navigation.state === 'submitting';
return (
<>
<h1>Checkout</h1>
<ul>
{lines.map((line) => <li key={line.isbn}>{line.quantity} × {line.title} · {formatPrice(line.lineTotal)}</li>)}
</ul>
<p className="total">Total: {formatPrice(total)}</p>
{result?.error && <p className="error" role="alert">Order failed: {result.error}</p>}
<Form method="post">
<button type="submit" disabled={placing || lines.length === 0}>{placing ? 'Placing order…' : 'Place order'}</button>
</Form>
</>
);
}
<Form method="post"> tarayıcının form gönderimini yakalıyor ve route'un action'ını çağırıyor; action bir redirect döndürürse router sipariş sayfasına geçiyor, bir object döndürürse useActionData onu veriyor. Action bittikten sonra router o sayfadaki loader'ları yeniden çalıştırıyor, bu yüzden mutation sonrası "hangi veri bayatladı" sorusunun bir kısmını router kendisi cevaplıyor. Bizim verimiz TanStack Query cache'inde olduğu için invalidation'ı yine kendimiz yapıyoruz. Gezinme çıktısında giriş yapmamış kullanıcı /checkout'a tıklayınca /sign-in?redirectTo=/checkout'a gönderildi, giriş yaptıktan sonra ödeme sayfasına döndü ve sipariş /orders/1'e yönlendirdi.
redirectTo parametresini doğrudan navigate()'e vermek bir open redirect (saldırganın kendi sitesine yönlendirme yapabildiği açık) olurdu; SignInPage yalnız / ile başlayan ve // ile başlamayan değerleri kabul ediyor. Bu kontrolün bir istemci kontrolü olduğunu da unutma: middleware bir UX (kullanıcı deneyimi) aracı, güvenlik sınırı değil. Siparişin kime ait olduğunu sunucu doğrulamalı; gerçek authentication'ı on dördüncü yazıda kuracağız.
Lazy Route ile Code Splitting
Ödeme sayfası, kullanıcıların küçük bir kısmının açtığı bir ekran. lazy ile onu ayrı bir chunk'a ayırdık; Angular'daki loadComponent'in karşılığı. Production build:
> bookstore-react@0.0.0 build
> tsc -b && vite build
vite v8.3.8 building client environment for production...
transforming...
✓ 180 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.45 kB │ gzip: 0.29 kB
dist/assets/index-kdJ4VOl-.css 2.16 kB │ gzip: 0.83 kB
dist/assets/checkoutAction-DKsrLHwe.js 0.42 kB │ gzip: 0.30 kB
dist/assets/CheckoutPage-DWMT65wL.js 0.68 kB │ gzip: 0.42 kB
dist/assets/index-CIFBL3VZ.js 365.98 kB │ gzip: 115.03 kB
✓ built in 77ms
CheckoutPage ve checkoutAction ana bundle'dan çıkıp iki küçük dosyaya ayrıldı. Bu sayfa küçük olduğu için kazanç birkaç yüz byte; büyük bir yönetim paneli ya da grafik kütüphanesi kullanan bir rapor sayfasında fark yüzlerce kilobyte olabilir. Önce Tahmin Et'teki ikinci sorunun cevabı gezinme çıktısında: giriş yapmamış kullanıcı için de iki chunk indirildi, ardından middleware yönlendirdi. Router lazy yüklemeyi middleware'in sonucunu beklemeden başlatıyor, böylece izinli kullanıcı için gezinme hızlı kalıyor. Kodu gizli tutmak için lazy route'a güvenme: istemciye giden JavaScript zaten herkese açık.
Loader'ları ve action'ı component dosyalarından ayrı dosyalara koymamızın sebebi Oxlint'ti. İlk sürümde hepsi component'le aynı dosyadaydı:
> bookstore-react@0.0.0 lint
> oxlint
src/features/orders/OrderPage.tsx:10:23: warning react(only-export-components): Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.
src/features/checkout/CheckoutPage.tsx:11:23: warning react(only-export-components): Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.
src/features/catalog/BookPage.tsx:12:23: warning react(only-export-components): Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.
Vite'ın Fast Refresh'i bir dosyayı ancak yalnız component export ediyorsa state'i koruyarak yenileyebiliyor; aynı dosyadan bir loader da export edilince değişiklikte sayfa tamamen yenileniyor. Ayrı dosyalar ayrıca lazy route'ta component ile action'ın paralel indirilmesini sağlıyor; React Router dokümanındaki lazy örneği de Promise.all ile böyle.
Static Host'ta SPA Fallback
Kullanıcı /books/9780441172719 linkini yeni bir sekmede açtığında istek önce sunucuya gidiyor. Sunucuda böyle bir dosya yok; sunucu index.html'i dönmezse React Router hiç çalışmaz:
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:52143/books/9780441172719 # vite preview
200 text/html
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://127.0.0.1:52145/books/9780441172719 # plain static server
404 text/html;charset=utf-8
vite preview SPA fallback'i kendisi yapıyor; düz bir static sunucu (burada Python'un http.server'ı) 404 döndü. Production'da nginx'te try_files $uri /index.html;, Azure Static Web Apps'te navigationFallback, IIS'te (Microsoft'un Windows için web sunucusu) bir rewrite kuralı gerekiyor. Angular serisindeki "Angular Build ve Deploy: ng build, Environment Dosyaları, Budgets ve SPA Routing" yazısında anlattığımız ayarın aynısı; framework değişmiş olsa da sorun tarayıcı ile sunucu arasında. Deploy tarafını on dördüncü yazıda ayrıntılı kuracağız.
Üretimde Dikkat
Loader'larda waterfall'a dikkat et. Router eşleşen route'ların loader'larını paralel çalıştırıyor; ama bir loader içinde iki await'i sırayla yazarsan ya da bir component render olunca ikinci bir veri çekmeye başlarsa (bizim "More by Jane Austen" listemiz gibi) istekler yine sıraya girer. Kitap sayfasında bunu bilerek bıraktık: liste sayfanın ana içeriği değil ve gelmesi beklenmeden sayfa açılabiliyor.
Pending UI'ı ihmal etme. Yavaş bir loader, kullanıcının tıklamasının hiçbir şey yapmadığı izlenimini verir ve tekrar tıklatır. useNavigation ile global bir gösterge en azı; uzun süren sayfalarda ilgili linkin yanında bir spinner daha iyi.
Scroll davranışını kontrol et: SPA'da sayfa değiştiğinde tarayıcı scroll'u kendiliğinden başa almıyor. Data mode'da <ScrollRestoration /> component'i bunu yönetiyor; uzun bir katalogdan detaya geçen kullanıcı için önemli.
React Router 8.5, kendi view transition API'lerini React 19.3'teki <ViewTransition> component'i lehine deprecated ilan etti; v8 boyunca çalışmaya devam edecekler, v9'da kaldırılacaklar. Sayfa geçişi animasyonu ekleyeceksen doğrudan React'in component'iyle başla; on altıncı yazıda bu sürüm değişikliklerine bakacağız.
Sık Düşülen Tuzaklar
react-router-dom'dan import etmek: v7'de bir uyumluluk katmanıydı, v8'de paket yok. Eski bir blog yazısından kopyalanan kod npm install'da değil import'ta hata verir.
Aynı route'ta parametre değişince state'in sıfırlanacağını sanmak: yukarıda gördüğümüz gibi component yeniden kullanılır. Parametreye bağlı state'i bir key ile sıfırla.
data() ile hata fırlatırken statusText vermemek. İlk sürümde 404'ü yalnız status ile fırlatmıştık:
open /books/9780000000000 /books/9780000000000 | h1: 404 Internal Server Error
text: No book with ISBN 9780000000000
open /wishlist /wishlist | h1: Page not found
Router statusText verilmeyen hata response'larında varsayılan olarak "Internal Server Error" kullanıyor; 404'ün yanında bu metni görmek hem kullanıcıyı hem log'ları yanıltır.
Arama metnini her harfte push ile URL'e yazmak: geri tuşu kullanıcıyı harf harf geri götürür. Yazma sırasında replace: true, anlamlı adımlarda (sayfa değiştirme, filtre seçme) yeni geçmiş kaydı.
Middleware'i güvenlik sanmak: istemcide çalışan her kontrol devre dışı bırakılabilir. Yetki kontrolü API'de yapılır; middleware yalnız kullanıcıyı doğru ekrana götürür.
Küçük Bir Deneme
★☆☆ Kategori sayfası ekle: /categories/:slug route'u GET /api/books?category=classics sonucunu listelesin. Kategori listesini GET /api/categories'ten alan bir loader yaz ve header'a kategoriler için NavLink'ler koy.
★★☆ Sepet sayfasına sepet boşken girilmesin: /cart için bir middleware yaz, sepet boşsa kataloğa yönlendirsin. Middleware React dışında çalışırken sepetin içeriğini nereden okuyabilir? queryClient.getQueryData mı, yoksa yeni bir istek mi?
★★★ Katalogdaki arama için URL'e her harfte yazmak yerine yalnız debounce edilmiş değeri yaz. Arama kutusunun değeri artık nerede yaşamalı? Kullanıcı geri tuşuna bastığında kutunun URL ile tutarlı kalmasını nasıl sağlarsın?
Uygulama artık URL'lerle bölünmüş bir SPA. Ödeme sayfasında henüz bir form yok; yalnız tek bir buton var ve kullanıcının adı, e-postası kimlik bilgisinden geliyor. Sıradaki yazıda teslimat adresi ve kalemleri olan gerçek bir sipariş formu kuracak, React 19'un form action'larını, useActionState'i, useFormStatus'u ve react-hook-form ile zod'u karşılaştıracağız.