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

React'te Event'ler ve State: useState, Immutable Update ve Re-render Ne Zaman Olur?

Mert Özen 15 Kas 2026 12 dk 3 görüntülenme
React'te Event'ler ve State: useState, Immutable Update ve Re-render Ne Zaman Olur?

React'te state render başına bir snapshot'tır: setState değişkeni değiştirmez, bir sonraki render'ı ister; önceki değerden hesaplanan güncelleme için updater fonksiyonu, object ve array için her zaman yeni referans gerekir. Bookstore kataloğuna adet seçici ve sepet ekleyip setAmount(amount + 1)'in üç çağrıda 2 verdiğini, mutate edilen sepetin ekrana geç geldiğini ve tek event'teki üç güncellemenin tek render'a indiğini render log'larıyla gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler React 19.3, TypeScript 7.0, Vite 8.3 ve Node.js 24 ile denenmiştir; render log'ları ve ekran değerleri Playwright 1.65 ile headless Chromium'da, production build üzerinde toplanmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React istemcisinde her kitap kartına bir adet seçici ekliyoruz: "−", sayı, "+" ve "Add 1 to cart" butonu. Kartın kendi state'i var: const [amount, setAmount] = useState(1). Deneme için karta iki buton daha koyuyoruz. İkisi de adedi üç artırmaya çalışıyor:

  function addThreeWithValue() {
    setAmount(amount + 1);
    setAmount(amount + 1);
    setAmount(amount + 1);
    console.log(`value version: amount is still ${amount}`);
  }

  function addThreeWithUpdater() {
    setAmount((a) => a + 1);
    setAmount((a) => a + 1);
    setAmount((a) => a + 1);
    console.log(`updater version: amount is still ${amount}`);
  }

Adet 1 iken "+3 (value)"a tıklıyoruz. Ekranda kaç yazar, konsola ne düşer? Ardından "+3 (updater)"a tıklıyoruz. Şimdi kaç yazar? Angular'da amount.set(amount() + 1) satırını üç kez yazsaydın sonuç ne olurdu? Cevap, state'in snapshot olduğunu anlattığımız bölümde.

Bu yazıda React'te event'leri, useState'i, state'i immutable güncellemeyi ve React'in bir component'i ne zaman yeniden render ettiğini, Bookstore kataloğuna "sepete ekle" akışını kurarak görüyoruz.

Event Handler'lar ve React'in Event Object'i

React'te event'e bağlanmak bir prop vermekten ibaret: onClick, onChange, onSubmit. Değer bir fonksiyon; çağrının kendisi değil. onClick={handleAddClick} doğru, onClick={handleAddClick()} ise fonksiyonu render sırasında çağırır ve sonucunu (undefined) handler olarak verir. Argüman geçmek gerekiyorsa arrow function'a sarıyorsun: onClick={() => setAmount((a) => a - 1)}. Angular'daki (click)="add.emit(book().isbn)" template ifadesinin karşılığı bu.

Handler'a gelen argüman tarayıcının native event'i değil, React'in event object'i. react.dev bunu "synthetic event" olarak da anıyor: DOM (tarayıcının sayfayı tuttuğu node ağacı) event'leriyle aynı standarda uyuyor, bazı tarayıcı tutarsızlıklarını düzeltiyor ve gerekirse orijinal event'e e.nativeEvent ile ulaşılabiliyor. React handler'ları her element'e tek tek değil, root'a bağlıyor; senin açından fark etmiyor, e.stopPropagation() ve e.preventDefault() beklediğin gibi çalışıyor. Sıralama kutusunun handler'ı tipiyle birlikte şöyle:

  function handleSortChange(event: ChangeEvent<HTMLSelectElement>) {
    setSortBy(event.target.value as SortBy);
  }

ChangeEvent<HTMLSelectElement> sayesinde event.target.value string olarak tipli geliyor. Tarayıcıdaki event tiplerini TypeScript serisindeki "Tarayıcıda TypeScript: DOM Tipleri, querySelector ve Event'ler" yazısı anlattı; React tipleri bunların üstüne kurulu.

useState ve Local State

Adet seçici eklenmiş kart:

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

type BookCardProps = {
  book: BookSummary;
  quantityInCart: number;
  onAdd: (book: BookSummary, amount: number) => void;
};

const maxAmount = 10;

export function BookCard({ book, quantityInCart, onAdd }: BookCardProps) {
  // Local state: kaç kopya seçildiği yalnız bu kartı ilgilendirir
  const [amount, setAmount] = useState(1);

  function handleAddClick() {
    onAdd(book, amount);
    setAmount(1);
  }

  return (
    <li className="book-card">
      <strong>{book.title}</strong>
      <span className="muted">
        {book.author} · {book.publishedYear}
        {book.publishedYear < 1900 && <span className="badge">Classic</span>}
      </span>
      <strong>{formatPrice(book.price)}</strong>
      <div className="amount">
        <button type="button" aria-label={`Fewer copies of ${book.title}`} disabled={amount === 1}
          onClick={() => setAmount((a) => a - 1)}>−</button>
        <span aria-live="polite">{amount}</span>
        <button type="button" aria-label={`More copies of ${book.title}`} disabled={amount === maxAmount}
          onClick={() => setAmount((a) => a + 1)}>+</button>
      </div>
      <button type="button" onClick={handleAddClick}>Add {amount} to cart</button>
      {quantityInCart > 0 && <span className="muted">In cart: {quantityInCart}</span>}
    </li>
  );
}

useState(1) component'e bir hafıza veriyor. Fonksiyon her render'da baştan çalışsa da React amount'u kendi tarafında, bu kartın ağaçtaki yerine bağlı olarak saklıyor ve bir sonraki çağrıda geri veriyor. Angular'da bu değer class'ın bir field'ı ya da signal'ı olurdu; class instance'ı yaşadığı için değer de yaşar. React'te instance yok, hafızayı React tutuyor; bu yüzden hook'lar her render'da aynı sırayla çağrılmalı.

Her kartın amount'u ayrı. Beş kart beş ayrı state demek; birinde "+"ya basmak diğerlerini etkilemiyor. Kartın dışında kimse bu değeri bilmiyor ve bilmesi de gerekmiyor: seçilen adet ancak "Add" butonuna basılınca onAdd(book, amount) ile yukarı gidiyor. Bu, Angular'da bir component'in kendi signal'ını private tutup dışarıya yalnız output() ile haber vermesine denk.

State Bir Snapshot'tır: Updater Fonksiyonu

"Önce Tahmin Et"teki iki butona sırayla tıkladığımızda Playwright'ın okuduğu değerler ve konsol:

console: value version: amount is still 1
amount after +3 (value):   2
console: updater version: amount is still 2
amount after +3 (updater): 5

İlk buton adedi 1'den 2'ye çıkardı, 4'e değil. Sebep, state'in render başına bir snapshot olması. Handler çalıştığı anda amount o render'ın değeri, yani 1. setAmount(amount + 1) üç kez "bir sonraki render'da amount 2 olsun" diyor. Üç istek de aynı şeyi istediği için sonuç 2. Konsol da bunu doğruluyor: üç setAmount çağrısından sonra bile handler'ın içindeki amount hâlâ 1. setAmount değişkeni değiştirmiyor; React'ten bir sonraki render'ı istiyor.

İkinci buton updater fonksiyonu kullanıyor: setAmount((a) => a + 1). Burada React'e bir değer değil, "önceki değeri al, bir ekle" tarifi veriliyor. React güncellemeleri kuyruğa alıyor ve bir sonraki render'da sırayla uyguluyor: 2 → 3 → 4 → 5. Ekranda 5 yazdı, handler'daki amount ise yine o render'ın değeri olan 2.

Angular signal'larında durum farklı: amount.set(amount() + 1) signal'ı anında değiştirir ve bir sonraki satırdaki amount() yeni değeri okur. Üç satır art arda adedi 4 yapar. React'te ise handler bitene kadar ekran ve değişkenler eski render'a ait. Kural pratikte şu: yeni değer öncekinden hesaplanıyorsa updater fonksiyonu kullan. Kartın "+" ve "−" butonlarında bu yüzden setAmount((a) => a + 1) yazdık.

Object ve Array State'ini Immutable Güncellemek

Sepet App'te bir array: her satır CartLine tipinde bir object.

// Sepetteki bir satır; Bookstore.Api'deki CartLine ile aynı alan adları
export type CartLine = {
  isbn: string;
  title: string;
  unitPrice: number;
  quantity: number;
};
import { useState, type ChangeEvent } from 'react';
import { CartSummary } from '@/features/cart/CartSummary';
import type { CartLine } from '@/features/cart/types';
import { BookList } from '@/features/catalog/BookList';
import { sampleBooks } from '@/features/catalog/sample-books';
import type { BookSummary } from '@/features/catalog/types';
import { Layout } from '@/shared/Layout';

type SortBy = 'title' | 'price';

export default function App() {
  const [lines, setLines] = useState<CartLine[]>([]);
  const [sortBy, setSortBy] = useState<SortBy>('title');
  const [lastAdded, setLastAdded] = useState<string | null>(null);

  // Türetilmiş değerler: her render'da state'ten yeniden hesaplanır, saklanmaz
  const books = sampleBooks.toSorted((a, b) =>
    sortBy === 'price' ? a.price - b.price : a.title.localeCompare(b.title));
  const quantities = Object.fromEntries(lines.map((line) => [line.isbn, line.quantity]));
  const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0);
  const total = lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);

  function handleAdd(book: BookSummary, amount: number) {
    // Asla mutate etme: yeni bir array kur (değişen satır için de yeni bir object)
    setLines((current) =>
      current.some((line) => line.isbn === book.isbn)
        ? current.map((line) => (line.isbn === book.isbn ? { ...line, quantity: line.quantity + amount } : line))
        : [...current, { isbn: book.isbn, title: book.title, unitPrice: book.price, quantity: amount }]);
    setLastAdded(`${amount} × ${book.title}`);
  }

  function handleSortChange(event: ChangeEvent<HTMLSelectElement>) {
    setSortBy(event.target.value as SortBy);
  }

  return (
    <Layout title="Bookstore" headerExtra={<CartSummary itemCount={itemCount} total={total} />}>
      <h1>Catalog</h1>
      <label>
        Sort by{' '}
        <select value={sortBy} onChange={handleSortChange}>
          <option value="title">Title</option>
          <option value="price">Price</option>
        </select>
      </label>
      {lastAdded && <p className="muted" role="status">Added {lastAdded}</p>}
      <BookList books={books} quantities={quantities} onAdd={handleAdd} />
    </Layout>
  );
}

handleAdd sepeti hiç değiştirmiyor; yeni bir sepet kuruyor. Kitap zaten varsa map ile yeni bir array üretiyor ve değişen satır için spread ile yeni bir object oluşturuyor ({ ...line, quantity: ... }); diğer satırlar aynı referanslarla yeni array'e geçiyor. Kitap yoksa [...current, newLine] ile sona ekliyor. Sıralama için sort değil, ES2023 ile gelen toSorted kullanıyoruz; sort array'i yerinde değiştirirken toSorted yeni bir array döndürüyor. Bu method'ları JavaScript serisindeki "JavaScript Array Method'ları Derinlemesine: map, filter, reduce, toSorted ve Object.groupBy" yazısı ayrıntılı anlattı.

Neden bu kadar özen? React bir state'in değişip değişmediğine Object.is ile bakıyor; react.dev'e göre yeni değer öncekiyle aynıysa React o component'i ve altındakileri render etmeyi atlayabiliyor. Array'i yerinde değiştirip aynı referansı geri verirsen React değişiklik görmüyor. Bunu bilerek bozan bir handleAdd yazdık:

  function handleAdd(book: BookSummary, amount: number) {
    // YANLIŞ: React'in elindeki array'i değiştiriyor, sonra aynı referansı geri veriyor
    const line = lines.find((l) => l.isbn === book.isbn);
    if (line) {
      line.quantity += amount;
    } else {
      lines.push({ isbn: book.isbn, title: book.title, unitPrice: book.price, quantity: amount });
    }
    setLines(lines);
  }

Önce Dune'u, sonra Emma'yı ekleyip sıralama kutusunu değiştirdik:

after Add Dune   : Cart: 0 item(s) · ₺0.00
after Add Emma   : Cart: 0 item(s) · ₺0.00
after sort change: Cart: 2 item(s) · ₺738.90

İki tıklamadan sonra header hâlâ boş sepet gösteriyor; veriler aslında array'in içinde. Sıralama değişince App başka bir sebeple render edildi ve mutate edilmiş array birden ekrana çıktı. Bu tür hatalar en zor bulunanlar: ekran yanlış değil, gecikmeli ve başka bir etkileşime bağlı olarak doğru. Angular'da signal.update(items => { items.push(x); return items; }) yazmak aynı sorunu üretir, çünkü signal da varsayılan olarak referans eşitliğine bakar; Angular'daki immutable disiplin React'te de aynen geçerli.

Derin ve iç içe object'lerde spread zinciri uzuyor. react.dev böyle durumlar için state'i düzleştirmeyi ya da Immer kullanmayı öneriyor; structuredClone ile bütün object'i kopyalamak da çalışır ama değişmeyen satırların da yeni referans almasına yol açar ve ileride memo ile yapılacak optimizasyonları boşa çıkarır. Sepet gibi düz bir yapıda map ve spread yeterli.

Render Ne Zaman Olur? Batching ve Render Log'ları

Bir component üç sebeple yeniden render ediliyor: kendi state'i değişti, parent'ı render edildi ya da okuduğu bir context değişti (context'i yedinci yazıda göreceğiz). Bunu görmek için App ve BookCard fonksiyonlarının başına birer console.log koyup production build'de iki etkileşim yaptık:

  render App
  render BookCard 1984
  render BookCard Dune
  render BookCard Emma
  render BookCard Middlemarch
  render BookCard The Hobbit
--- click + on Dune (local state)
  render BookCard Dune
--- click Add on Dune (setLines + setLastAdded + setAmount)
  render App
  render BookCard 1984
  render BookCard Dune
  render BookCard Emma
  render BookCard Middlemarch
  render BookCard The Hobbit

"+" butonu yalnız Dune kartının kendi state'ini değiştirdi ve yalnız o kart render edildi; App ve diğer dört kart çalışmadı. "Add" butonu ise aynı event içinde üç güncelleme yaptı: handleAdd içinde setLines ve setLastAdded, kartın içinde setAmount(1). Log'da tek bir render App var: React aynı event içindeki güncellemeleri biriktirip handler bittikten sonra tek bir render'da uyguluyor. Buna batching deniyor ve React 18'den beri setTimeout, promise ya da native event handler'larındaki güncellemeler için de otomatik.

Log'daki ikinci ders: App render edilince beş kartın hepsi de render edildi, sepetle ilgisi olmayan 1984 dahil. React'in varsayılanı bu; parent render edilince child'lar da çağrılıyor. Kartların props'u değişmediyse ortaya çıkan JSX (JavaScript içine yazılan HTML benzeri syntax) aynı, dolayısıyla DOM'a bir şey yazılmıyor; maliyet yalnız fonksiyon çağrısı. Beş kart için önemsiz, beş bin kart için ölçülmesi gereken bir şey. memo, useMemo ve React Compiler'ın bu çağrıları nasıl atladığını performans yazısında ölçeceğiz. Bir güncellemenin baştan sona yolculuğu:

04-render-cycle-tr.png

Normal akışta ekranda gördüklerimiz:

after + +      : Add 3 to cart | Cart: 0 item(s) · ₺0.00
after Add      : Add 1 to cart | Cart: 3 item(s) · ₺749.70 | Added 3 × Emma | In cart: 3
sort by title  : 1984, Dune, Emma, Middlemarch, The Hobbit
sort by price  : Emma, 1984, The Hobbit, Middlemarch, Dune

"Add 3 to cart"a basmadan önce header boş; adet seçici yalnız kartın state'iydi. Ekledikten sonra sepet 3 adet ve 749,70 lira, kartın adedi 1'e döndü, sıralama değişince liste fiyata göre yeniden dizildi. Sıralanmış liste, adetler, toplam ve ürün sayısı state değil; hepsi her render'da lines ve sortBy'dan hesaplanıyor.

Üretimde Dikkat

Bir handler'da birden fazla state'i güncellemek sorun değil; batching onları tek render'a indiriyor. Sorun, birbirine bağlı iki state'i ayrı ayrı tutup birini güncellemeyi unutmak. Sepetteki toplamı ayrı bir state'e koysaydık her ekleme, silme ve adet değişikliğinde onu da güncellemek gerekirdi. Hesaplanabilen değeri hesapla; bunun kurallarını bir sonraki yazıda konuşacağız.

Mutation hataları production'da rastgele görünür: bir kullanıcı sepete ekler, hiçbir şey olmaz, başka bir yere tıklayınca ürün belirir. Code review'da push, splice, sort, reverse ve obj.x = ... atamalarına, state'ten gelen bir değer üzerinde yapılıyorsa dikkat et.

Bu yazıdaki sepet tarayıcıda yaşıyor ve sayfa yenilenince kayboluyor. Bookstore'un gerçek sepeti API'de (/api/carts); onu TanStack Query ile bağladığımızda client state ile server state ayrımı netleşecek.

Sık Düşülen Tuzaklar

setX'ten hemen sonra x'i okumak: yeni değer bir sonraki render'da gelir. Hemen lazım olan değeri bir değişkende hesapla ve hem setX'e hem de kullanacağın yere onu ver.

Önceki değere dayanan güncellemeyi değerle yazmak: setAmount(amount + 1) tek bir tıklamada çalışıyor gibi görünür, ama aynı handler'da iki kez çağrıldığında ya da bir setTimeout içinde eski bir closure'dan çalıştığında yanlış sonuç verir. Updater fonksiyonu bu iki durumu da çözüyor.

Handler yerine çağrıyı vermek: kartta onClick={onAdd(book, amount)} yazınca onAdd her render'da çalışır. TypeScript bunu derlemede yakalıyor, çünkü onAdd'in dönüş tipi void:

src/features/catalog/BookCard.tsx(37,29): error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.

Tip kontrolünü atlayan dev server'da aynı kodu açtığımızda konsol "Cannot update a component while rendering a different component" hatasıyla doldu ve 1,5 saniye sonra header "Cart: 102366 item(s)" gösteriyordu: her render sepete ekliyor, her ekleme yeni bir render tetikliyordu. Argüman gerekiyorsa onClick={() => onAdd(book, amount)}.

State'i doğrudan değiştirip setX(sameReference) çağırmak: yukarıdaki gecikmeli sepet. StrictMode bunu her zaman yakalamaz, çünkü mutation render'da değil handler'da yapılıyor.

Küçük Bir Deneme

★☆☆ Kartın adet seçicisine bir üst sınır uyarısı ekle: adet 10'a ulaşınca kartın altında "Max 10 copies" yazsın. Bu metin için yeni bir state gerekiyor mu?

★★☆ App'e "Remove last added" butonu ekle: son eklenen satırı sepetten çıkarsın. filter ile yaz ve yazdığın kodun mevcut array'i değiştirmediğinden emin ol. Aynı işi pop() ile yazınca ne oluyor, render log'unda gör.

★★★ "+" butonunun handler'ını setTimeout(() => setAmount(amount + 1), 1000) yap ve bir saniye içinde üç kez tıkla. Sonuç kaç? Updater fonksiyonuyla tekrar dene ve farkı state snapshot'ı ile açıkla.

Sepet şu an App'te, arama ve detay paneli gibi yeni ekranlar eklendikçe state'in nerede durması gerektiği sorusu büyüyor. Sıradaki yazıda lifting state up, controlled input'lar, türetilmiş state'in kuralları ve sepeti useReducer ile tip güvenli bir reducer'a taşımayı göreceğiz.