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

State'i Nerede Tutmalı? Lifting State Up, Controlled Input'lar, Türetilmiş State ve useReducer

Mert Özen 15 Kas 2026 15 dk 2 görüntülenme
State'i Nerede Tutmalı? Lifting State Up, Controlled Input'lar, Türetilmiş State ve useReducer

State, ona ihtiyaç duyan component'lerin en yakın ortak atasında yaşamalı; hesaplanabilen değer state'e konmaz, kopya yerine kimlik saklanır, prop değişince sıfırlanacak state için key verilir. Bookstore kataloğunda sepeti App'e taşıyıp useReducer ve discriminated union ile tip güvenli bir reducer'a çeviriyor, ayrı tutulan toplamın silmede yanlış kaldığını, key'siz detay panelinin notu başka kitaba taşıdığını ve reducer'ın Node.js'te doğrudan çalıştığını gerçek çıktılarla 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; reducer ayrıca Node.js 24'ün tip silme özelliğiyle doğrudan çalıştırılmış, tarayıcı davranışı Playwright 1.65 ile headless Chromium'da gözlenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) React kataloğunda kitabın adına tıklayınca listenin altında bir detay paneli açılıyor. Panelde "Gift note" adında bir input var; not, panelin kendi state'i:

export function BookDetailsPanel({ book }: BookDetailsPanelProps) {
  // Tek bir kitaba ait; parent bunu key={book.isbn} ile sıfırlar
  const [giftNote, setGiftNote] = useState('');

Kullanıcı Dune'un detayını açıp nota "For Grace" yazıyor, sonra Emma'nın adına tıklıyor. Panel aynı component, yalnızca book prop'u değişti. Emma'nın panelinde not ne olur: boş mu, yoksa hâlâ "For Grace" mi? Panelin render edildiği satır <BookDetailsPanel book={selectedBook} /> ile <BookDetailsPanel key={selectedBook.isbn} book={selectedBook} /> arasında fark var mı? Angular'da @if içindeki bir component'in input'u değişince component yeniden mi oluşturulur? Cevap, key bölümünde.

Bu yazının sorusu state'in nerede yaşaması gerektiği. Katalog büyüdü: arama kutusu, sıralama, detay paneli, header'daki sepet özeti ve yan tarafta sepet paneli var. Hangi state hangi component'te durmalı, hangi değer hiç state olmamalı ve sepet mantığı ne zaman useState'ten useReducer'a taşınmalı?

Lifting State Up: Sepeti Kim Tutmalı?

Sepeti üç yer kullanıyor: header'daki özet, kartlardaki "In cart" sayısı ve yandaki sepet paneli. Bunların hepsini çizen en yakın ortak component App. React'te iki component aynı veriyi görmek zorundaysa veri ikisinin en yakın ortak atasına taşınıyor ve aşağıya props olarak iniyor; değişiklik ise callback'lerle yukarı çıkıyor. Buna "lifting state up" deniyor:

import { useReducer } from 'react';
import { CartPanel } from '@/features/cart/CartPanel';
import { cartItemCount, cartReducer, cartTotal } from '@/features/cart/cartReducer';
import { CartSummary } from '@/features/cart/CartSummary';
import { CatalogPage } from '@/features/catalog/CatalogPage';
import type { BookSummary } from '@/features/catalog/types';
import { Layout } from '@/shared/Layout';

export default function App() {
  // Yukarı taşınan state: header, katalog ve sepet paneli sepete ihtiyaç duyuyor
  const [lines, dispatch] = useReducer(cartReducer, []);
  const quantities = Object.fromEntries(lines.map((line) => [line.isbn, line.quantity]));

  function handleAdd(book: BookSummary, amount: number) {
    dispatch({ type: 'added', book, amount });
  }

  return (
    <Layout title="Bookstore" headerExtra={<CartSummary itemCount={cartItemCount(lines)} total={cartTotal(lines)} />}>
      <div className="page">
        <div>
          <CatalogPage quantities={quantities} onAdd={handleAdd} />
        </div>
        <CartPanel lines={lines} dispatch={dispatch} />
      </div>
    </Layout>
  );
}

Angular'da aynı ihtiyaç için iki yol vardır: parent bir signal tutar, child'a input() ile verir ve child output() ile haber verir; ya da veri root'ta tek instance olan bir service'e konur ve herkes inject eder. React'te ikinci yolun karşılığı Context ve harici store'lar (Zustand gibi); onları ilerideki yazılarda göreceğiz. İlk yol ise tam olarak burada yaptığımız şey. Kural, state'i ihtiyaç duyan component'lerin en yakın ortak atasına koymak, daha yukarıya değil. Arama metni, sıralama ve seçili kitap yalnız kataloğu ilgilendirdiği için App'e değil CatalogPage'e kondu:

import { useState } from 'react';
import { BookDetailsPanel } from '@/features/catalog/BookDetailsPanel';
import { BookList } from '@/features/catalog/BookList';
import { sampleBooks } from '@/features/catalog/sample-books';
import { SearchBox } from '@/features/catalog/SearchBox';
import type { BookSummary } from '@/features/catalog/types';

type SortBy = 'title' | 'price';

type CatalogPageProps = {
  quantities: Record<string, number>;
  onAdd: (book: BookSummary, amount: number) => void;
};

export function CatalogPage({ quantities, onAdd }: CatalogPageProps) {
  // Yalnız kataloğun ihtiyaç duyduğu state burada kalır, App'e çıkmaz
  const [query, setQuery] = useState('');
  const [sortBy, setSortBy] = useState<SortBy>('title');
  // Kitap object'inin kopyasını değil, kimliğini sakla
  const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);

  const term = query.trim().toLowerCase();
  const books = sampleBooks
    .filter((b) => b.title.toLowerCase().includes(term) || b.author.toLowerCase().includes(term))
    .toSorted((a, b) => (sortBy === 'price' ? a.price - b.price : a.title.localeCompare(b.title)));
  const selectedBook = sampleBooks.find((b) => b.isbn === selectedIsbn) ?? null;

  return (
    <>
      <h1>Catalog</h1>
      <div className="toolbar">
        <SearchBox value={query} onChange={setQuery} />
        <label>
          Sort by{' '}
          <select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortBy)}>
            <option value="title">Title</option>
            <option value="price">Price</option>
          </select>
        </label>
      </div>
      <p className="muted">{books.length} of {sampleBooks.length} books</p>
      <BookList books={books} quantities={quantities} onAdd={onAdd} onSelect={setSelectedIsbn} />
      {selectedBook && <BookDetailsPanel key={selectedBook.isbn} book={selectedBook} />}
    </>
  );
}

Bu ayrımın somut bir faydası var: kullanıcı arama kutusuna yazarken yalnız CatalogPage ve altındakiler render ediliyor; header ve sepet paneli etkilenmiyor. Arama metnini App'e koysaydık her tuş vuruşu bütün sayfayı render ederdi.

Controlled Input: Metnin Sahibi Kim?

Arama kutusu bir controlled input: değerini kendi tutmuyor, parent'tan alıyor ve her değişikliği parent'a bildiriyor.

type SearchBoxProps = {
  value: string;
  onChange: (value: string) => void;
};

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

Akış şöyle: kullanıcı bir harf yazar, onChange yeni metni setQuery'ye verir, CatalogPage yeniden render edilir ve input'a yeni value iner. Ekrandaki metin her an state'in kendisi. Angular'daki [value] + (input) ikilisinin, ya da ngModel'ın açık yazılmış hâli bu. Two-way binding yok; veri bir yönde iniyor, değişiklik bir yönde çıkıyor.

Zincirin bir halkasını unutunca ne olduğunu görmek için onChange'i kaldırdık ve geliştirme modunda input'a "Dune" yazdık:

error: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
input value after typing Dune: ""

React hem uyarıyor hem de input'u kilitliyor: value state'ten geliyor, state değişmediği için her tuş vuruşu geri alınıyor. Uncontrolled input da geçerli bir seçenek: defaultValue ile başlangıç değeri verilir, değeri DOM (tarayıcının sayfayı tuttuğu node ağacı) tutar ve gerektiğinde form gönderilirken okunur. Detay panelindeki not alanı gibi her tuş vuruşunda başka bir şeyi etkileyen alanlar controlled, büyük formlarda ise çoğu zaman uncontrolled ve bir form kütüphanesi tercih ediliyor; bunu form yazısında karşılaştıracağız.

Türetilmiş Değeri State'e Koyma

CatalogPage'te filtrelenmiş ve sıralanmış liste, "1 of 5 books" metni ve seçili kitap state değil. Üçü de her render'da query, sortBy ve selectedIsbn'dan hesaplanıyor. Sepet tarafında da toplam ve ürün sayısı cartTotal(lines) ve cartItemCount(lines) ile hesaplanıyor. react.dev'in state yapısı rehberi bunu "redundant state'ten kaçın" diye özetliyor: bir bilgi props'tan ya da mevcut state'ten render sırasında hesaplanabiliyorsa ayrı bir state olmamalı.

Kuralı bozmanın bedelini görmek için toplamı ayrı bir state'e koyan bir App yazdık:

  // Yukarı taşınan state: header, katalog ve sepet paneli sepete ihtiyaç duyuyor
  const [lines, dispatch] = useReducer(cartReducer, []);
  // YANLIŞ: hesaplanabilen bir şeyin kopyası; yalnız handleAdd onu güncel tutuyor
  const [total, setTotal] = useState(0);
  const quantities = Object.fromEntries(lines.map((line) => [line.isbn, line.quantity]));

  function handleAdd(book: BookSummary, amount: number) {
    dispatch({ type: 'added', book, amount });
    setTotal((t) => t + book.price * amount);
  }

Dune ve Emma'yı ekleyip sepet panelinden Dune'u çıkardık:

add Dune, Emma : Cart: 2 item(s) · ₺738.90 | panel Total: ₺738.90
remove Dune    : Cart: 1 item(s) · ₺738.90 | panel Total: ₺249.90

Panel doğru toplamı (249,90) gösterirken header hâlâ 738,90 diyor. Toplamı güncel tutma görevini handleAdd üstlendi; silme ve adet değiştirme yolları ise bundan habersiz. Yeni her action bu listeyi uzatıyor. Hesaplanan değerde böyle bir senkronizasyon işi yok. Pahalı bir hesaplama söz konusuysa (binlerce satırlık bir filtre gibi) useMemo sonucu saklayabilir, ama bu yine state değil, bir cache; performans yazısında ne zaman gerektiğini ölçeceğiz.

State Yapısı: Kopya Değil, Kimlik Sakla

Seçili kitap için useState<BookSummary | null> yazmak akla ilk gelen. Biz selectedIsbn sakladık ve kitabı listeden bulduk. Sebep, react.dev'in "duplication'dan kaçın" ilkesi: kitap object'inin bir kopyası state'e girerse liste güncellendiğinde (fiyat değişti, API yeni veri döndü) panel eski kopyayı göstermeye devam eder. Kimlik saklamak, verinin tek bir yerde durmasını sağlıyor.

Aynı rehberin diğer ilkeleri de Bookstore'da karşılık buluyor. Hep birlikte değişen değerleri gruplamak: sepetin satırları tek bir array, her satır kendi object'i. Çelişkili state'ten kaçınmak: isLoading ve isError'u iki ayrı boolean yapmak yerine tek bir status alanı kullanmak; bunu altıncı yazıda arama sonuçları için yapacağız. İç içe derin state yerine düz yapı: kategori içinde kitap, kitap içinde yorum gibi ağaçları normalize etmek.

key ile State'i Sıfırlamak

Detay paneli:

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

type BookDetailsPanelProps = {
  book: BookSummary;
};

export function BookDetailsPanel({ book }: BookDetailsPanelProps) {
  // Tek bir kitaba ait; parent bunu key={book.isbn} ile sıfırlar
  const [giftNote, setGiftNote] = useState('');

  return (
    <section className="details" aria-label="Book details">
      <h2>{book.title}</h2>
      <p>{book.author}, {book.publishedYear} · {book.category} · {formatPrice(book.price)}</p>
      <label>
        Gift note{' '}
        <input value={giftNote} onChange={(e) => setGiftNote(e.target.value)} maxLength={120} />
      </label>
      <p className="muted">{120 - giftNote.length} characters left</p>
    </section>
  );
}

"Önce Tahmin Et"teki senaryoyu iki şekilde çalıştırdık. key olmadan:

Dune details        : Dune | note = For Grace | 111 characters left
switch to Emma      : Emma | note = For Grace | 111 characters left

key={selectedBook.isbn} ile (son iki satır):

search "aus"        : 1 of 5 books | Emma
add 2 Dune, 1 Emma  : Cart: 3 item(s) · ₺1,227.90 | Total: ₺1,227.90
Dune quantity -> 0  : Cart: 1 item(s) · ₺249.90 | Emma  ₺249.90 Remove
Dune details        : Dune | note = For Grace | 111 characters left
switch to Emma      : Emma | note =  | 120 characters left

React bir component'in state'ini ağaçtaki yerine göre saklıyor. key yokken Dune'dan Emma'ya geçişte aynı yerde aynı tip component (BookDetailsPanel) render edildi; React bunu "aynı panel, yeni props" diye yorumladı ve not state'ini korudu. Emma'nın paneli Grace'e yazılmış notla açıldı. key verdiğimizde React farklı key'i farklı bir component olarak görüyor: eski paneli kaldırıp yenisini sıfır state ile oluşturuyor.

Angular'da durum farklı: @if (selectedBook(); as book) içindeki bir component'in input'u değişince Angular component'i yeniden oluşturmaz, yalnız input'u günceller; component'in kendi field'ları korunur. Sıfırlamak için ya ngOnChanges/effect ile elle temizlik yaparsın ya da Angular 19'daki linkedSignal ile state'i input'a bağlarsın. React'te bunun karşılığı bir effect değil, key. "Prop değişince state'i sıfırla" ihtiyacı için effect yazmak, bir sonraki yazıda göreceğimiz yaygın hatalardan biri.

useReducer ile Sepet

Sepetin değişebileceği yollar arttı: ekle, adet değiştir, çıkar, temizle. Bu mantığı component'lerin handler'larına dağıtmak yerine tek bir reducer fonksiyonunda topluyoruz:

import type { CartLine } from '@/features/cart/types';
import type { BookSummary } from '@/features/catalog/types';

// Sepetin değişebileceği her yol, "type" üzerinden bir discriminated union
export type CartAction =
  | { type: 'added'; book: BookSummary; amount: number }
  | { type: 'quantityChanged'; isbn: string; quantity: number }
  | { type: 'removed'; isbn: string }
  | { type: 'cleared' };

// Saf fonksiyon: aynı (state, action) her zaman aynı yeni state'i verir; mutation yok, I/O yok
export function cartReducer(lines: CartLine[], action: CartAction): CartLine[] {
  switch (action.type) {
    case 'added': {
      const { book, amount } = action;
      return lines.some((line) => line.isbn === book.isbn)
        ? lines.map((line) => (line.isbn === book.isbn ? { ...line, quantity: line.quantity + amount } : line))
        : [...lines, { isbn: book.isbn, title: book.title, unitPrice: book.price, quantity: amount }];
    }
    case 'quantityChanged':
      return action.quantity <= 0
        ? lines.filter((line) => line.isbn !== action.isbn)
        : lines.map((line) => (line.isbn === action.isbn ? { ...line, quantity: action.quantity } : line));
    case 'removed':
      return lines.filter((line) => line.isbn !== action.isbn);
    case 'cleared':
      return [];
    default: {
      // Yeni bir action tipi eklenip yukarıda ele alınmazsa bu satır derlenmez
      const unhandled: never = action;
      throw new Error(`Unknown cart action: ${JSON.stringify(unhandled)}`);
    }
  }
}

export function cartItemCount(lines: CartLine[]): number {
  return lines.reduce((sum, line) => sum + line.quantity, 0);
}

export function cartTotal(lines: CartLine[]): number {
  return lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
}

Reducer iki şey alıyor: o anki state ve ne olduğunu anlatan bir action. Yeni state'i döndürüyor. Action'lar TypeScript'te type alanı üzerinden bir discriminated union; switch her dalda action'ın tipini daraltıyor, 'added' dalında action.book'a erişebiliyorken 'removed' dalında erişemiyorsun. Bu tekniğin temeli TypeScript serisindeki "TypeScript'te Union ve Literal Tipler: Bir Değer, Birkaç İhtimal" yazısında. Action isimleri geçmiş zaman (added, removed): "ne yapılsın" değil, "ne oldu" anlatıyorlar.

useReducer(cartReducer, []) bir [state, dispatch] çifti döndürüyor. Component'ler artık sepeti nasıl güncelleyeceklerini bilmiyor, yalnız ne olduğunu bildiriyor. Sepet paneli dispatch'i doğrudan alıyor:

import type { Dispatch } from 'react';
import { cartTotal, type CartAction } from '@/features/cart/cartReducer';
import type { CartLine } from '@/features/cart/types';
import { formatPrice } from '@/shared/format';

type CartPanelProps = {
  lines: CartLine[];
  dispatch: Dispatch<CartAction>;
};

export function CartPanel({ lines, dispatch }: CartPanelProps) {
  if (lines.length === 0) {
    return <aside className="cart-panel muted">Your cart is empty.</aside>;
  }

  return (
    <aside className="cart-panel">
      <h2>Cart</h2>
      <ul>
        {lines.map((line) => (
          <li key={line.isbn}>
            {line.title}{' '}
            <input type="number" min={0} max={99} aria-label={`Quantity of ${line.title}`} value={line.quantity}
              onChange={(e) => dispatch({ type: 'quantityChanged', isbn: line.isbn, quantity: e.target.valueAsNumber || 0 })} />{' '}
            {formatPrice(line.unitPrice * line.quantity)}{' '}
            <button type="button" onClick={() => dispatch({ type: 'removed', isbn: line.isbn })}>Remove</button>
          </li>
        ))}
      </ul>
      <p className="total">Total: {formatPrice(cartTotal(lines))}</p>
      <button type="button" onClick={() => dispatch({ type: 'cleared' })}>Clear cart</button>
    </aside>
  );
}

Tip güvenliğinin iki faydasını derleyici çıktısında görüyoruz. Action adını yanlış yazmak:

src/App.tsx(15,16): error TS2322: Type '"add"' is not assignable to type '"added" | "cleared" | "quantityChanged" | "removed"'.

Union'a yeni bir action ekleyip reducer'da ele almayı unutmak (default dalındaki never ataması sayesinde):

src/features/cart/cartReducer.ts(31,13): error TS2322: Type '{ type: "couponApplied"; code: string; }' is not assignable to type 'never'.

Reducer'ın en büyük kazancı test edilebilirlik. React'e, DOM'a ya da tarayıcıya bağımlı değil; düz bir fonksiyon. Node.js 24 TypeScript dosyalarındaki tipleri silerek doğrudan çalıştırabildiği için reducer'ı hiçbir test altyapısı kurmadan denedik:

import { cartItemCount, cartReducer, cartTotal, type CartAction } from './cartReducer.ts';

const dune = { isbn: '9780441172719', title: 'Dune', author: 'Frank Herbert', category: 'Science Fiction', price: 489, publishedYear: 1965 };
const emma = { isbn: '9780141439587', title: 'Emma', author: 'Jane Austen', category: 'Classics', price: 249.9, publishedYear: 1815 };

const actions: CartAction[] = [
  { type: 'added', book: dune, amount: 1 },
  { type: 'added', book: emma, amount: 2 },
  { type: 'added', book: dune, amount: 1 },
  { type: 'quantityChanged', isbn: emma.isbn, quantity: 0 },
];

let lines = cartReducer([], actions[0]);
for (const action of actions.slice(1)) {
  const next = cartReducer(lines, action);
  console.log(action.type.padEnd(16), JSON.stringify(next.map((l) => `${l.title} × ${l.quantity}`)), 'same array?', next === lines);
  lines = next;
}
console.log('items:', cartItemCount(lines), 'total:', cartTotal(lines));
// Reducer tekrar oynatılabilir: aynı action'lar yine aynı state'i verir
console.log('replay equal:', JSON.stringify(actions.reduce(cartReducer, [])) === JSON.stringify(lines));
added            ["Dune × 1","Emma × 2"] same array? false
added            ["Dune × 2","Emma × 2"] same array? false
quantityChanged  ["Dune × 2"] same array? false
items: 2 total: 978
replay equal: true

Her action yeni bir array döndürdü (same array? false), Emma'nın adedi 0'a çekilince satır silindi, aynı action listesini reduce ile baştan oynatmak aynı sonucu verdi. Vitest ile düzenli birim testleri test yazısında yazacağız; reducer orada en kolay test edilen parça olacak.

Ne zaman useReducer? Tek bir değeri tutan basit state için (kartın adet seçicisi, açık/kapalı bir panel) useState yeterli. Aynı veriyi değiştiren birden fazla yol varsa, bir güncelleme birkaç alana dokunuyorsa ya da mantığı component'ten ayırıp test etmek istiyorsan reducer daha iyi.

Flux ve Container/Presentational ile Bağ

Buradaki yapı yeni bir fikir değil. Action'ların bir dispatcher üzerinden tek yönde akması ve state'i yalnız reducer'ın değiştirmesi Flux mimarisinin özü; UI Mimari Pattern'leri serisindeki "Flux ve Unidirectional Data Flow: Two-Way Binding Neden Ölçeklenmedi?" yazısı bunun tarihini ve gerekçesini anlattı. NgRx'in reducer'ları da aynı fikirden geliyor. App ve CatalogPage veriyi tutan container'lar, BookCard, SearchBox ve CartSummary ise yalnız props alıp callback çağıran presentational component'ler; aynı serinin "Component Architecture: Container ve Presentational Component'ler, Composition ve Contract'lar" yazısındaki ayrım. Bu ayrımı katı bir kural olarak değil, "hangi component state'in sahibi?" sorusunu cevaplamanın bir yolu olarak kullanıyoruz.

Üretimde Dikkat

Lifting state up küçük ağaçlarda iyi çalışıyor, ama sepeti on seviye aşağıdaki bir butona props ile taşımak (prop drilling) can sıkıcı hâle geliyor. Bu noktada Context ya da bir store devreye giriyor; yedinci ve on birinci yazıların konusu. Önce state'i en yakın ortak ataya koy, sorun gerçekten çıkınca bir sonraki araca geç.

Reducer'da yan etki yok: HTTP isteği, localStorage yazma, Date.now() ya da crypto.randomUUID() çağrısı reducer'a girmez. StrictMode geliştirmede reducer'ı da iki kez çağırıyor; yan etkisi olan bir reducer bunu hemen belli eder. Kimlik ya da zaman gerekiyorsa değeri action'ı dispatch eden yer üretip action'ın içine koymalı.

Bookstore'un gerçek sepeti API'de. O zaman bu reducer'ın yeri değişecek: sunucudan gelen sepet server state olacak ve TanStack Query'nin cache'inde duracak, reducer ise ödeme formunun adımları gibi tarayıcıya ait state'te kalacak.

Sık Düşülen Tuzaklar

Her şeyi App'e taşımak: "nasılsa bir gün lazım olur" diye arama metnini, sıralamayı ve seçimi en tepeye koymak her tuş vuruşunda bütün ağacı render eder. State'i ihtiyaç duyan en alttaki ortak ataya koy.

Props'u state'e kopyalamak: const [title, setTitle] = useState(book.title) yalnız ilk render'daki değeri alır; book değişince title güncellenmez. Prop'u doğrudan kullan, sıfırlama gerekiyorsa key ver.

Türetilmiş değeri state'te tutmak: yukarıdaki header ile panelin farklı toplam göstermesi. Toplam, adet, filtrelenmiş liste hesaplanır.

Reducer'da state'i mutate etmek: lines.push(...) ve return lines bir önceki yazıdaki gecikmeli sepet hatasını reducer'a taşır. Reducer da her zaman yeni bir değer döndürmeli.

Küçük Bir Deneme

★☆☆ Arama kutusunun yanına bir "Clear" butonu ekle. Butonun SearchBox'ın içinde mi, CatalogPage'te mi durması gerektiğine karar ver; seçimini state'in sahibi üzerinden açıkla.

★★☆ Reducer'a { type: 'quantityIncreased'; isbn: string } action'ı ekle ve sepet panelinde her satıra bir "+" butonu koy. Derleyicinin seni nerede durdurduğunu gör, sonra reducer-demo.ts'e bu action için bir satır ekle.

★★★ Detay panelinde key kullanmadan, notu kitaba göre saklayan bir yapı kur: Record<string, string> tipinde bir state ile her kitabın notu ayrı dursun ve kitaplar arasında gidip gelince kaybolmasın. Bu state nerede yaşamalı?

Şu ana kadarki bütün state tarayıcıda doğdu ve tarayıcıda kaldı; kitap listesi bile sabit bir dosyadan geliyor. Sıradaki yazıda listeyi Bookstore API'den çekiyoruz: useEffect'in gerçek amacı, cleanup, dependency array, StrictMode'da çift çalışma, race condition ve "effect'e ihtiyacın olmayabilir" ilkesi.