Mert Özen Her Satırda Daha İleri
Frontend React — #07

useRef, Context ve Custom Hook'lar: DOM Erişimi, DI Yerine Context ve Hook Kuralları

Mert Özen 15 Kas 2026 19 dk 3 görüntülenme
useRef, Context ve Custom Hook'lar: DOM Erişimi, DI Yerine Context ve Hook Kuralları

useRef render tetiklemeyen bir kutudur (DOM node'u ya da timer id'si), Context ise bir değeri ağacın derinlerine props'suz taşır ve değişince onu okuyan her component'i render eder. Bookstore'da sepet, tema ve kullanıcıyı Context'e taşıyıp tema değişiminin 2 yerine 28 component render ettirdiği sürümü ölçüyor, inline ref callback'inin odak kaçırdığını, koşullu hook'un uygulamayı çökerttiğini ve aramayı custom hook'lara ayırmayı gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler React 19.3, 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ış, render sayıları ve konsol 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 istemcisinde sepet şimdiye kadar App'te bir useReducer ile tutuluyordu ve quantities, cartItemCount, onAdd gibi props'lar katalog sayfasına tek tek iniyordu. Bu yazıda sepeti, temayı (açık/koyu) ve giriş yapmış kullanıcıyı birer Context'e taşıyoruz. Angular'da bunları üç service'e koyup inject() ile alırdın.

İki soru. Header'daki "Dark theme" butonuna basıldığında kaç component yeniden render edilir: yalnız buton mu, yoksa 20 kitap kartıyla birlikte bütün sayfa mı? Peki tema state'i Context yerine App'te bir useState olsaydı ve butona props ile verilseydi? Cevapları yazının ortasında gerçek render sayılarıyla göreceğiz.

useRef: Render'ı Tetiklemeyen Bir Kutu

useRef sana { current: ... } şeklinde bir object verir. React bu object'i component'in ömrü boyunca aynı tutar; current'ı değiştirmen render tetiklemez. İki tipik kullanımı var: bir DOM (tarayıcının sayfayı tuttuğu node ağacı) node'una erişmek ve ekranda görünmeyen ama render'lar arasında yaşaması gereken bir değeri saklamak.

İlki Angular'daki @ViewChild ya da viewChild() karşılığı. Katalogda kullanıcı sayfanın herhangi bir yerinde / tuşuna basınca arama kutusuna odaklanmak istiyoruz:

export function CatalogPage() {
  const [query, setQuery] = useState('');
  const [sortBy, setSortBy] = useState<SortBy>('title');
  const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);
  const { lines, itemCount, add } = useCart();
  // @ViewChild gibi: <input> DOM node'una bir tutamak, commit'ten sonra dolar
  const searchInput = useRef<HTMLInputElement>(null);

  const { result, isLoading } = useBookSearch(query, (searched, total) => {
    console.info(`[search] "${searched}" -> ${total} book(s), cart has ${itemCount} item(s)`);
  });
  useKeyboardShortcut('/', () => searchInput.current?.focus());
  // Her zaman çağrılır; koşul hook'un etrafında değil, callback'in içinde
  useKeyboardShortcut('Escape', () => setSelectedIsbn(null));

searchInput.current ilk render sırasında null; React DOM'u oluşturup commit ettikten sonra input'u oraya yazıyor. Bu yüzden ref'i render sırasında okumak ya da yazmak yanlış: react.dev, ref'i yalnız ilk değer atamada render içinde kullanmaya izin veriyor. Ref'ler event handler'larda ve effect'lerde okunur. useKeyboardShortcut birazdan göreceğimiz custom hook; / tuşuna basılınca verdiğimiz fonksiyonu çağırıyor.

İkinci kullanım, render ile ilgisi olmayan değerler. Sepet paneline "Removed Dune. Undo" mesajı ekledik; mesaj beş saniye sonra kayboluyor. Timer'ın id'si ekranda hiçbir şeyi değiştirmiyor ama "Undo"ya basılınca ya da ikinci bir ürün silinince ona ulaşıp timer'ı iptal etmemiz gerekiyor:

export function CartPanel() {
  const { lines, total, setQuantity, remove, restore, clear } = useCart();
  const [removed, setRemoved] = useState<CartLine | null>(null);
  // Timer id'si ekranda görünmüyor: ref onu render tetiklemeden saklar
  const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  function stopUndoTimer() {
    if (undoTimer.current !== null) {
      clearTimeout(undoTimer.current);
      undoTimer.current = null;
    }
  }

  function handleRemove(line: CartLine) {
    remove(line.isbn);
    setRemoved(line);
    stopUndoTimer();
    undoTimer.current = setTimeout(() => setRemoved(null), undoMs);
  }

  function handleUndo(line: CartLine) {
    stopUndoTimer();
    restore(line);
    setRemoved(null);
  }

  // Panel unmount olunca arkada timer bırakma
  useEffect(() => stopUndoTimer, []);

Timer id'sini useState'te tutsaydık her silmede gereksiz bir render daha olurdu ve id'yi set ettiğimiz render'daki handler eski değeri görürdü. Sıradan bir let değişkeni ise her render'da sıfırlanırdı. Kural basit: değer ekranda görünüyorsa state, görünmüyorsa ve render'lar arasında yaşaması gerekiyorsa ref. Son satırdaki effect, panel unmount olursa timer'ı arkada bırakmamak için.

Dev sunucusunda sayfayı açıp /'ye bastık, "Dune" yazdık, kitabı sepete ekleyip sildik, "Undo" dedik, sonra giriş yapıp temayla oynadık:

   91 ms request:  ?search=&pageSize=50
   92 ms request:  ?search=&pageSize=50
   93 ms failed:   ?search=&pageSize=50 net::ERR_ABORTED
  117 ms console: [search] "" -> 20 book(s), cart has 0 item(s)
  133 ms focus:    BODY
  142 ms after /:  Search books
  662 ms request:  ?search=Dune&pageSize=50
  674 ms console: [search] "Dune" -> 1 book(s), cart has 0 item(s)
  720 ms header:   Cart: 1 item(s) · ₺489.00
  751 ms cart:     Cart | Removed Dune. Undo | Your cart is empty.
  780 ms cart:     Cart | Dune  ₺489.00 Remove | Total: ₺489.00 | Clear cart
  812 ms user:     Bookstore | Cart: 1 item(s) · ₺489.00 | Dark theme | Ada Lovelace Sign out
  813 ms theme:    light
  916 ms OS dark:  dark
  928 ms clicked:  light
 1030 ms OS light: light (user choice wins)

İlk satırlara dikkat: bir önceki yazıda ilk yüklemede tek istek gidiyordu, şimdi StrictMode'da iki istek gidiyor ve ilki iptal ediliyor. Sebep, debounce'u ayrı bir hook'a taşımamız: useDebouncedValue ilk render'da değeri beklemeden döndürüyor, bu yüzden ilk istek timer'sız, effect'in içinde başlıyor ve StrictMode'un kurup söktüğü ilk effect'in isteği abort() ile kesiliyor. Production'da tek istek gider.

React 19'da ref Bir Prop, Ref Callback'leri ve Cleanup

Arama kutusu kendi component'imiz; React 18'de bir ref'i kendi component'ine iletmek için forwardRef gerekiyordu. React 19'da ref function component'ler için sıradan bir prop:

import type { Ref } from 'react';

type SearchBoxProps = {
  value: string;
  onChange: (value: string) => void;
  // React 19: ref sıradan bir prop, forwardRef gerekmez
  ref?: Ref<HTMLInputElement>;
};

// Controlled input: metnin sahibi parent; input yalnız gösterir ve değişikliği bildirir
export function SearchBox({ value, onChange, ref }: SearchBoxProps) {
  return (
    <input ref={ref} type="search" placeholder="Search by title or author (press /)" aria-label="Search books"
      value={value} onChange={(e) => onChange(e.target.value)} />
  );
}

ref prop'una bir object yerine fonksiyon da verebilirsin (ref callback). React node DOM'a bağlandığında fonksiyonu node ile çağırır; React 19'da bu fonksiyon bir cleanup döndürebilir ve node ayrılırken o çalışır. Detay panelini açınca paneli görünür alana kaydırıp odağı ona taşımak, panel kapanınca da odağı paneli açan butona geri vermek istiyoruz; panelin yeni hâli useId ile birlikte:

import { useId, useState } from 'react';
import type { BookSummary } from '@/features/catalog/types';
import { formatPrice } from '@/shared/format';

type BookDetailsPanelProps = {
  book: BookSummary;
  onClose: () => void;
};

const maxNote = 120;

// Component'in dışında tanımlı: her render'da aynı fonksiyon, React onu yalnız bağlanırken çağırır
function revealPanel(node: HTMLElement) {
  const opener = document.activeElement;
  node.scrollIntoView({ block: 'nearest' });
  node.focus();
  // Cleanup (React 19): panel kalkarken odak içindeyse odağı paneli açan element'e geri ver
  return () => {
    if (node.contains(document.activeElement) && opener instanceof HTMLElement) {
      opener.focus();
    }
  };
}

export function BookDetailsPanel({ book, onClose }: BookDetailsPanelProps) {
  // Tek bir kitaba ait; parent bunu key={book.isbn} ile sıfırlar
  const [giftNote, setGiftNote] = useState('');
  // Her component instance'ı için benzersiz, render'lar arasında sabit: id/htmlFor/aria-* için güvenli
  const noteId = useId();

  return (
    <section ref={revealPanel} tabIndex={-1} className="details" aria-label="Book details">
      <h2>{book.title}</h2>
      <p>{book.author}, {book.publishedYear} · {book.category} · {formatPrice(book.price)}</p>
      <label htmlFor={noteId}>Gift note</label>{' '}
      <input id={noteId} aria-describedby={`${noteId}-hint`} value={giftNote}
        onChange={(e) => setGiftNote(e.target.value)} maxLength={maxNote} />
      <p id={`${noteId}-hint`} className="muted">{maxNote - giftNote.length} characters left</p>
      <button type="button" onClick={onClose}>Close</button>
    </section>
  );
}

revealPanel'in component'in dışında tanımlı olması bilerek seçildi. Önce aynı mantığı inline bir arrow function olarak yazıp attach ve cleanup anlarını log'ladık. Dune'u açıp hediye notu alanına "Hi!" yazdık, sonra Emma'ya geçtik:

-- click "Dune"
[ref] attach Dune
[ref] cleanup Dune
[ref] attach Dune
-- click the gift note and type "Hi!"
[ref] cleanup Dune
[ref] attach Dune
gift note value: "H"
-- click "Emma"
[ref] cleanup Dune
[ref] attach Emma
[ref] cleanup Emma
[ref] attach Emma

Her render'da yeni bir fonksiyon oluştuğu için React her render'da eski ref'in cleanup'ını çalıştırıp yenisini bağladı. Bizim callback focus() da çağırdığından ilk harften sonra odak input'tan panele kaçtı ve alana yalnız "H" yazılabildi. Aynı log'ları module seviyesindeki revealPanel ile aldığımızda:

-- click "Dune"
[ref] attach Dune
[ref] cleanup Dune
[ref] attach Dune
-- click the gift note and type "Hi!"
gift note value: "Hi!"
-- click "Emma"
[ref] cleanup Dune
[ref] attach Emma
[ref] cleanup Emma
[ref] attach Emma

Yazarken hiçbir ref çağrısı olmadı, not tamamı yazıldı. Her iki koşuda da mount anında görülen "attach, cleanup, attach" üçlüsü StrictMode'dan: React 19 geliştirmede ref callback'lerini de effect'ler gibi bir kez söküp yeniden bağlıyor. Ref callback'in props ya da state'e ihtiyacı varsa onu useCallback ile sabitlersin; bu hook on ikinci yazının konusu.

Cleanup'ın bir görevi daha var. İlk sürümümüzde revealPanel hiçbir şey döndürmüyordu ve Dune'a tıklayınca panel hiç açılmadı:

pageerror: Cannot read properties of null (reading 'scrollIntoView')
pageerror: Cannot read properties of null (reading 'scrollIntoView')
after click "Dune":  focus = BODY | panels: 0

Geriye dönük uyumluluk için React, cleanup döndürmeyen bir ref callback'ini node ayrılırken null ile tekrar çağırıyor; StrictMode'un mount'taki söküp bağlaması da bunu ilk tıklamada tetikledi ve null.scrollIntoView() bütün ağacı düşürdü. TypeScript bunu yakalamadı, çünkü React'in tiplerinde ref callback parametresi bilerek esnek (bivariant) tanımlı ve (node: HTMLElement) => void kabul ediliyor. react.dev bu null çağrısının ileride kaldırılacağını söylüyor; doğru yazım cleanup döndürmek. Bizim cleanup'ımız işe yarar bir şey de yapıyor:

after click "Dune":  focus = Book details | panels: 1
after "Close":       focus = Dune | panels: 0
after Emma + Escape: focus = Emma | panels: 0

Kapatınca odak Dune butonuna döndü. Emma'da Escape'e bastığımızda odak paneldeydi, yine butona döndü. Kullanıcı başka bir kitaba tıklayarak paneli değiştirirse odak zaten o butonda olduğu için cleanup ona dokunmuyor.

Context: DI Yerine Ağaç Üzerinden Değer Taşımak

React'te Angular'daki gibi bir DI (dependency injection; bağımlılıkları bir container'ın oluşturup vermesi) container'ı yok. Bir değeri ağacın derinlerine props geçirmeden ulaştırmanın yolu Context. Sepet için:

import { createContext, use } from 'react';
import type { CartLine } from '@/features/cart/types';
import type { BookSummary } from '@/features/catalog/types';

export type CartContextValue = {
  lines: CartLine[];
  itemCount: number;
  total: number;
  add: (book: BookSummary, amount: number) => void;
  setQuantity: (isbn: string, quantity: number) => void;
  remove: (isbn: string) => void;
  restore: (line: CartLine) => void;
  clear: () => void;
};

// Default null: sepeti <CartProvider> dışında okumak bir durum değil, bir hata
export const CartContext = createContext<CartContextValue | null>(null);

export function useCart(): CartContextValue {
  const cart = use(CartContext);
  if (cart === null) {
    throw new Error('useCart must be used inside <CartProvider>');
  }
  return cart;
}
import { useReducer, type ReactNode } from 'react';
import { CartContext, type CartContextValue } from '@/features/cart/CartContext';
import { cartItemCount, cartReducer, cartTotal } from '@/features/cart/cartReducer';

// Sepet state'inin sahibi; altındaki her component onu useCart() ile okuyabilir
export function CartProvider({ children }: { children: ReactNode }) {
  const [lines, dispatch] = useReducer(cartReducer, []);

  const value: CartContextValue = {
    lines,
    itemCount: cartItemCount(lines),
    total: cartTotal(lines),
    add: (book, amount) => dispatch({ type: 'added', book, amount }),
    setQuantity: (isbn, quantity) => dispatch({ type: 'quantityChanged', isbn, quantity }),
    remove: (isbn) => dispatch({ type: 'removed', isbn }),
    restore: (line) => dispatch({ type: 'restored', line }),
    clear: () => dispatch({ type: 'cleared' }),
  };

  return <CartContext value={value}>{children}</CartContext>;
}

createContext bir anahtar oluşturuyor; Angular'daki InjectionToken'a benziyor. React 19'dan itibaren provider olarak doğrudan <CartContext value={...}> render ediliyor; eski <CartContext.Provider> yazımı hâlâ çalışıyor ama react.dev onu legacy olarak listeliyor. Okumak için useContext(CartContext) ya da React 19'un use(CartContext)'i kullanılıyor. İkisi aynı değeri döndürüyor; fark, use'un bir if ya da döngü içinde de çağrılabilmesi.

Default değer seçimi bir tasarım kararı. Sepet için null verdik ve useCart provider yoksa exception fırlatıyor, çünkü sepetsiz bir katalog hatadır ve bunu sessizce boş bir sepetle örtmek istemeyiz. Tema içinse anlamlı bir default var (açık tema); provider olmasa da component'ler çalışıyor. Provider'ları main.tsx'te ağacın tepesine koyduk:

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'

// 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>
    <ThemeProvider>
      <AuthProvider>
        <CartProvider>
          <App />
        </CartProvider>
      </AuthProvider>
    </ThemeProvider>
  </StrictMode>,
)

Angular'ın hiyerarşik injector'üyle benzerlik ve farklar şöyle:

KonuAngular DIReact Context
Anahtarclass ya da InjectionTokencreateContext() ile oluşan object
KayıtprovidedIn: 'root', providers: []ağaçta <Ctx value={...}> render etmek
Çözümlemeen yakın injector, yoksa üst injectoren yakın provider, yoksa default değer
Ne taşınırbir service instance'ı (çoğunlukla singleton)herhangi bir değer; her render'da değişebilir
Değişincesignal'ı okuyan template'ler güncellenirContext'i okuyan her component yeniden render edilir

Son satır en önemlisi. Angular'da service bir kez oluşur ve içindeki signal'lar değişir; React'te Context'in değeri bir state'ten türetilir ve değer değişince o Context'i okuyan bütün component'ler yeniden render edilir. Sepet Context'imiz her render'da yeni bir value object'i oluşturuyor, ama CartProvider yalnız kendi state'i değişince render edildiği için bu bir sorun değil. Neden öyle olduğunu ölçerek görelim.

Context Değişince Kim Render Edilir?

Her component'in başına bir console.log('[render] ...') satırı ekleyen bir sürümü production build'de çalıştırdık. Sayfa yüklendikten sonra temayı değiştirdik, giriş yaptık ve Dune'u sepete ekledik:

page load:             ThemeProvider, AuthProvider, CartProvider, App, Layout, CartSummary, ThemeToggle, UserMenu, CatalogPage×3, SearchBox×2, CartPanel, BookList, BookCard×20
toggle theme:          ThemeProvider, ThemeToggle
sign in:               AuthProvider, UserMenu
add Dune to cart:      CartProvider, CartSummary, CatalogPage, SearchBox, BookList, BookCard×20, CartPanel

Tema değişince yalnız ThemeProvider ve temayı okuyan ThemeToggle render edildi; giriş yapınca yalnız AuthProvider ile UserMenu. Bunun sebebi provider'ların children almaları: ThemeProvider'ın state'i değişince React onu yeniden çağırıyor, ama children bir önceki render'daki element'lerin aynısı olduğu için React o alt ağaca inmiyor; yalnız Context'i okuyanları buluyor. Sepette ise CatalogPage sepeti okuyor ve kartlar ona props ile bağlı, bu yüzden 20 kart render edildi.

Aynı temayı App'te bir useState olarak tutup butonu orada render eden sürümle:

page load:             ThemeProvider, AuthProvider, CartProvider, App, Layout, CartSummary, UserMenu, CatalogPage×3, SearchBox×2, CartPanel, BookList, BookCard×20
toggle theme:          App, Layout, CartSummary, UserMenu, CatalogPage, SearchBox, BookList, BookCard×20, CartPanel
sign in:               AuthProvider, UserMenu
add Dune to cart:      CartProvider, CartSummary, CatalogPage, SearchBox, BookList, BookCard×20, CartPanel

Tek bir buton yazısı için 28 component render edildi. Ekran ikisinde de aynı; fark, React'in her seferinde yaptığı iş. 20 kitapta bu milisaniyeler bile etmez, ama sepeti okuyan component sayısı arttıkça Context'in "okuyan herkesi render et" davranışı hissedilir hâle gelir. Context'te seçici abonelik yok: sepetin yalnız itemCount'unu okuyan bir component de sepetteki her değişiklikte render edilir. Bu sınırı on birinci yazıda Zustand'ın selector'larıyla, gereksiz render'ların maliyetini on ikinci yazıda ölçeceğiz.

Custom Hook'lar: Mantığı Paylaş, State'i Değil

Altıncı yazıda arama effect'i doğrudan CatalogPage'in içindeydi. Şimdi iki hook'a ayrıldı:

import { useEffect, useState } from 'react';

// value'yu döndürür, ama yalnız delayMs boyunca değişmediğinde
export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}
import { useEffect, useEffectEvent, useState } from 'react';
import type { BookSummary, PagedResult } from '@/features/catalog/types';
import { useDebouncedValue } from '@/shared/useDebouncedValue';

export type SearchResult =
  | { status: 'success'; query: string; books: BookSummary[]; total: number }
  | { status: 'error'; query: string; message: string };

// Yüklenirken result henüz olmayabilir ya da eski bir query'ye ait olabilir
export type BookSearch =
  | { isLoading: true; result: SearchResult | null }
  | { isLoading: false; result: SearchResult };

const debounceMs = 300;

// Altıncı yazıdaki arama effect'i, sayfadan çıkarılıp tekrar kullanılabilir bir hook'a taşındı
export function useBookSearch(query: string, onFinished: (query: string, total: number) => void): BookSearch {
  const debouncedQuery = useDebouncedValue(query, debounceMs);
  const [result, setResult] = useState<SearchResult | null>(null);
  const handleFinished = useEffectEvent(onFinished);

Custom hook, adı use ile başlayan ve içinde başka hook'lar çağıran sıradan bir fonksiyon. Angular'daki bir service'ten önemli bir farkı var: hook state paylaşmaz, mantık paylaşır. useDebouncedValue'yu iki component'te çağırırsan iki ayrı useState ve iki ayrı timer oluşur. Ortak bir değer gerekiyorsa onu Context'e (ya da ileride bir store'a) koyar, hook'u o değeri okumak için kullanırsın; useCart tam olarak bu.

Klavye kısayolu hook'u bir önceki yazıdaki useEffectEvent'i tekrar kullanıyor:

import { useEffect, useEffectEvent } from 'react';

// Tuşa sayfanın herhangi bir yerinde basılınca onPress'i çağırır; bir alana yazı yazılırken çağırmaz
export function useKeyboardShortcut(key: string, onPress: () => void) {
  const handlePress = useEffectEvent(onPress);

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      const target = e.target as HTMLElement;
      if (e.key !== key || target.closest('input, textarea, select')) {
        return;
      }
      e.preventDefault();
      handlePress();
    }
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [key]);
}

onPress her render'da yeni bir fonksiyon. Onu dependency array'e koysaydık listener her render'da sökülüp yeniden takılırdı; useEffectEvent sayesinde listener yalnız key değişince yenileniyor ama her zaman son onPress'i çağırıyor. Yukarıdaki çıktıda /'ye basınca odağın arama kutusuna geçtiği satır bu hook'un işi.

Ne zaman custom hook yazmalı? Aynı effect'i iki yerde kopyaladığını gördüğünde, ya da bir component'te "bu satırlar ne yapıyor" sorusunun cevabı tek bir isimle verilebiliyorsa (useBookSearch). Her useState'i bir hook'a sarmak ise okuru bir dosya daha açmaya zorlamaktan başka bir şey kazandırmaz.

Rules of Hooks ve Nedeni

İki kural var: hook'ları yalnız component'lerde ve custom hook'larda çağır; ve her zaman en üst seviyede, koşul, döngü, iç fonksiyon ya da erken return'den önce çağır. Detay paneli açıkken Escape ile kapatma kısayolunu ilk denemede şöyle yazdık:

  const selectedBook = books.find((b) => b.isbn === selectedIsbn) ?? null;
  // YANLIŞ: koşul içinde bir hook
  if (selectedBook !== null) {
    useKeyboardShortcut('Escape', () => setSelectedIsbn(null));
  }
> bookstore-react@0.0.0 lint
> oxlint

src/features/catalog/CatalogPage.tsx:31:5: error react-hooks(rules-of-hooks): React Hook "useKeyboardShortcut" is called conditionally. React Hooks must be called in the exact same order in every component render. help: Move the Hook call before the condition, or call it unconditionally and branch inside the Hook/effect instead.
exit code: 1

Lint'i görmezden gelip sayfada Dune'a tıkladık:

pageerror: Rendered more hooks than during the previous render.
console.error: React has detected a change in the order of Hooks called by CatalogPage. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks

   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. useState                   useState
3. useState                   useState
4. useRef                     useRef
5. useState                   useState
6. useEffect                  useEffect
7. useState                   useState
8. useEffectEvent             useEffectEvent
9. useEffect                  useEffect
10. useEffectEvent            useEffectEvent
11. useEffect                 useEffect
12. undefined                 useEffectEvent
root children: 0

Hata, kuralın nedenini kendisi anlatıyor. React hook'ları isimle değil sırayla tanıyor: her component için çağrı sırasına göre bir liste tutuyor ve bir sonraki render'da birinci useState'in birinci kayıt olduğunu varsayıyor. Listede useCart'ın hiç görünmemesi de ilginç: içindeki use bir hook sayılmıyor, bu yüzden koşul içinde çağrılabiliyor. Kitap seçilince listeye 12. bir hook eklendi ve React render'ı tamamlayamadı. Error boundary olmadığı için bütün ağaç unmount oldu; #root'un çocuk sayısı sıfır. Doğru sürüm hook'u her zaman çağırıp koşulu callback'in içine almak; snapshot'taki kodda Escape kısayolu böyle duruyor ve detay paneli kapalıyken Escape'e basmak zaten bir şey değiştirmiyor.

useId ve useSyncExternalStore

Detay panelinde useId kullandık. Bir label'ı input'a htmlFor/id ile, açıklamayı aria-describedby ile bağlamak için sayfada benzersiz bir id gerekiyor; sabit bir "gift-note" yazarsak aynı component iki kez render edildiğinde id çakışır. useId component instance'ı başına sabit ve benzersiz bir değer üretiyor. Liste key'i için kullanılmaz; key veriden gelir.

Temanın varsayılanı işletim sisteminin açık/koyu tercihini izliyor. Bu tercih React'in dışında yaşayan bir değer; matchMedia ile okunuyor ve değişince bir event yayınlıyor. Böyle "dış store"lara abone olmanın doğru yolu useSyncExternalStore:

import { useSyncExternalStore } from 'react';

const query = '(prefers-color-scheme: dark)';

// İşletim sistemi/tarayıcı renk teması bir dış store: React abone olur ve bir snapshot okur
function subscribe(onChange: () => void) {
  const media = window.matchMedia(query);
  media.addEventListener('change', onChange);
  return () => media.removeEventListener('change', onChange);
}

function getSnapshot() {
  return window.matchMedia(query).matches;
}

export function usePrefersDark(): boolean {
  return useSyncExternalStore(subscribe, getSnapshot);
}
import { useEffect, useState, type ReactNode } from 'react';
import { ThemeContext, type Theme } from '@/shared/theme/ThemeContext';
import { usePrefersDark } from '@/shared/usePrefersDark';

export function ThemeProvider({ children }: { children: ReactNode }) {
  const prefersDark = usePrefersDark();
  // null = kullanıcı bir tema seçene kadar sistem ayarını izle
  const [choice, setChoice] = useState<Theme | null>(null);
  const theme: Theme = choice ?? (prefersDark ? 'dark' : 'light');

  // <html data-theme> React ağacının dışında: bir effect onu senkron tutar
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  const value = { theme, toggleTheme: () => setChoice(theme === 'dark' ? 'light' : 'dark') };
  return <ThemeContext value={value}>{children}</ThemeContext>;
}

React subscribe'ı bir kez çağırıp değişiklikleri dinliyor, her değişiklikte getSnapshot'ı okuyor ve değer farklıysa render ediyor. subscribe'ı component'in dışında tanımlamak önemli; içeride tanımlarsan her render'da yeni bir fonksiyon olur ve React her render'da yeniden abone olur. Yukarıdaki çıktıda Playwright'ın emulateMedia ile sistem temasını koyuya çevirmesi sayfayı koyu yaptı; kullanıcı butonla açık temayı seçtikten sonra sistem teması değişse de kullanıcının seçimi geçerli kaldı. Bu hook'u on birinci yazıda tekrar göreceğiz: Zustand gibi store kütüphaneleri React'e bu API ile bağlanıyor.

Üretimde Dikkat

Context bir state yönetim aracı değil, bir taşıma aracı. Az değişen ve çok yerde okunan değerler (tema, dil, giriş yapmış kullanıcı, feature flag'ler) için çok uygun. Sık değişen ve birçok component'in farklı parçalarını okuduğu bir değeri (sepetin satırları, bir form'un alanları, sunucudan gelen listeler) tek bir Context'e koyarsan her değişiklikte okuyan herkes render edilir. İlk önlem Context'leri bölmek: tema, kullanıcı ve sepeti üç ayrı Context'e koymamızın sebebi bu.

AuthProvider şu an yalnız bir demo: kullanıcı bellekte tutuluyor, sayfa yenilenince kayboluyor ve sunucu bundan habersiz. Gerçek authentication (kimlik doğrulama) için token'ı tarayıcıda saklamak yerine, SPA'nın (single-page application; tek HTML sayfasında çalışan uygulama) yanına cookie ile çalışan ince bir sunucu katmanı koyan BFF (Backend for Frontend; frontend'e özel backend katmanı) yaklaşımını on dördüncü yazıda kuracağız.

Ref ile DOM'a dokunurken React'in yönettiği şeyleri değiştirme. Odak, scroll, ölçüm, üçüncü parti bir kütüphaneye node vermek ref'in işi; bir node'un çocuklarını elle silmek ya da metnini değiştirmek React'in bir sonraki commit'inde ya geri alınır ya da hataya yol açar.

Sık Düşülen Tuzaklar

Render sırasında ref okumak: {inputRef.current?.value} gibi bir ifade ilk render'da boş, sonrakilerde bir önceki commit'in değeri olur. Ekranda görünecek değer state'tir.

Inline ref callback'i: yukarıda gördüğümüz gibi her render'da yeniden çalışır. İçinde yalnız atama varsa zararsızdır; odak, ölçüm ya da abonelik varsa fonksiyonu dışarı al ya da useCallback ile sabitle.

Provider'ı yanlış yere koymak: useCart'ı provider'ın dışında çağıran bir component exception alır; bir provider'ı yanlışlıkla bir modal'ın içine koymak ise o modal açıldığında ayrı, boş bir sepet oluşturur. Angular'da bir service'i component'in providers'ına koyunca yeni instance oluşmasına benzer.

Custom hook'un state paylaştığını sanmak: iki component'te çağrılan useDebouncedValue iki ayrı değer üretir. Paylaşılan state ortak bir üst component'te, Context'te ya da bir store'da yaşar.

Hook'u koşula sokmak ya da erken return'den sonra çağırmak: lint'i her zaman açık tut; React'in hatası çoğu zaman kullanıcı belirli bir yola girene kadar ortaya çıkmaz.

Küçük Bir Deneme

★☆☆ Sepet panelindeki "Undo" mesajına beş saniyelik süreyi gösteren bir geri sayım ekle ("Undo (4)"). Geri sayımın değeri state mi olmalı, ref mi? Interval'in id'si nerede durmalı?

★★☆ ThemeProvider'ı children almak yerine <App />'i kendi içinde render edecek şekilde değiştir ve render log'larıyla karşılaştır. Provider'ın children almasının neden önemli olduğunu kendi sayılarınla göster.

★★★ Sepet Context'ini ikiye böl: biri lines, itemCount ve total'ı (state), diğeri yalnız fonksiyonları (add, remove…) taşısın. Fonksiyon Context'inin değeri render'dan render'a nasıl sabit kalır? Yalnız "Add" butonları olan bir component'in sepet değişince hâlâ render edilip edilmediğini ölç.

Context ile değer dağıtmayı, custom hook'larla mantığı toplamayı ve ref ile DOM'a dokunmayı gördük. Arama hâlâ elle yazdığımız bir effect ve sonuçlar hiçbir yerde cache'lenmiyor. Sıradaki yazıda bu effect'i TanStack Query'ye taşıyacak, sepeti tarayıcıdan Bookstore API'deki sunucu tarafı sepete (/api/carts) bağlayacak ve Suspense ile use()'un veri çekmedeki yerine bakacağız.