Frontend’de bazı ekranlar vardır; başlangıçta oldukça basit görünür ama yeni özellikler geldikçe hızla karmaşıklaşır. Checkout sayfası bunun en güzel örneklerinden biri.

Bir e-ticaret sitesinde ülke seçimi kargoyu etkiler, kargo seçimi ödeme yöntemlerini değiştirir, kupon toplam tutarı değiştirir, toplam tutar da ücretsiz kargo sınırını etkiler. Yani bileşenler aslında birbirleriyle sürekli etkileşim halindedir.

Sorun şu: Her bileşeni diğer bileşenlere bağlamaya başladığınızda prop drilling, callback zincirleri ve birbirini tanıyan component’ler ortaya çıkar.

Bunun yerine araya bir Mediator (Arabulucu) koyabiliriz. React tarafında bu arabulucunun görevini Zustand veya Redux gibi merkezi bir store üstlenebilir. Bileşenler birbirleriyle konuşmak yerine sadece Mediator ile konuşur.

Gerçek bir senaryo: Checkout

Trendyol, Amazon veya Shopify gibi bir e-ticaret platformunun ödeme sayfasını düşünün. Ekranda kabaca şu bileşenler var:

CountrySelector
ShippingMethodPicker
PaymentMethodPicker

CouponBox
OrderSummary

İlk bakışta bunların birbirinden bağımsız olduğunu düşünebilirsiniz. Ama gerçek iş kuralları biraz daha karmaşık:

  • Ülke değişirse: O ülkeye hizmet veren kargo firmaları değişir. Örneğin Yurtiçi Kargo Türkiye’de varken Almanya’da yoktur.
  • Kargo yöntemi değişirse: Kargo ücreti değişir ve bazı ödeme yöntemleri kullanılabilirliğini kaybedebilir. Örneğin kapıda ödeme sadece belirli yurt içi kargolarda kullanılabilir.
  • Kupon uygulanırsa: Ara toplam düşer. Eğer toplam tutar ücretsiz kargo sınırının altına inerse kargo tekrar ücretli hale gelir.

Dolayısıyla aslında ortada bir koordinasyon problemi vardır.

Buradaki önemli nokta şu: Bileşenlerin birbirini tanımasına gerek yok. CountrySelector, PaymentMethodPicker‘ın varlığından bile haberdar olmamalı.

Mediator ne yapıyor?

Mediator Pattern’in temel fikri oldukça basit:

Bileşenlerin birbiriyle doğrudan konuşması yerine, araya bir arabulucu koy.

Bizim örneğimizde bu arabulucu Zustand store’u olacak. Bileşen şunu söyleyecek:

"Ülke değişti."

Sonrasında:

Mediator
Kargo seçeneklerini güncelle
Ödeme seçeneklerini güncelle
Gerekirse seçili değerleri düzelt

Bileşenin bu zincirin geri kalanını bilmesine gerek yok.

Farkı en net şöyle görebilirsiniz:

// CheckoutPage bütün kuralların merkezine dönüşür
<CountrySelector
  value={country}
  onChange={(c) => {
    setCountry(c);
    const methods = shippingFor(c);
    setShippingMethods(methods);
    if (!methods.includes(shippingMethod)) setShippingMethod(methods[0]);
    setPaymentMethods(paymentFor(c, methods[0]));
    recalcShipping(c, subtotal);
  }}
/>
// Component tek bir şey söyler, gerisi Mediator'ın işi
<CountrySelector />

// içinde:
setCountry(country);

1. Mediator — Zustand Store

Koordinasyonun tamamını tek bir yerde topluyoruz:

// store/useCheckoutStore.js
import { create } from 'zustand';

const SHIPPING_PROVIDERS = {
  TR: ['yurtici', 'aras', 'mng'],
  DE: ['dhl'],
  US: ['ups', 'fedex'],
};

const FREE_SHIPPING_THRESHOLD = 150;

const useCheckoutStore = create((set, get) => ({
  country: 'TR',
  availableShippingMethods: SHIPPING_PROVIDERS.TR,
  shippingMethod: 'yurtici',

  paymentMethod: 'creditCard',
  availablePaymentMethods: ['creditCard', 'cashOnDelivery'],

  cartSubtotal: 320,
  shippingCost: 29.9,
  couponCode: null,

  // --- 1. KOORDİNASYON: Ülke Değişimi ---
  setCountry: (country) => {
    const methods = SHIPPING_PROVIDERS[country] ?? [];
    const currentStillValid = methods.includes(get().shippingMethod);

    set({
      country,
      availableShippingMethods: methods,
      // Seçili kargo yeni ülkede yoksa listedeki ilk kargoyu seç
      shippingMethod: currentStillValid ? get().shippingMethod : methods[0],
    });

    // Zincirleme etkileri tetikle
    get().recalculatePaymentOptions();
    get().recalculateShippingCost();
  },

  // --- 2. KOORDİNASYON: Kargo Yöntemi Değişimi ---
  setShippingMethod: (method) => {
    set({ shippingMethod: method });

    // Zincirleme etkileri tetikle
    get().recalculateShippingCost();
    get().recalculatePaymentOptions();
  },

  // --- 3. KOORDİNASYON: Kupon Uygulama ---
  applyCoupon: (code, discountAmount) => {
    set((state) => ({
      couponCode: code,
      cartSubtotal: state.cartSubtotal - discountAmount,
    }));

    // Kupon kargo eşiğini etkileyebilir, yeniden hesapla
    get().recalculateShippingCost();
  },

  // --- YARDIMCI MANTIK: Ödeme Seçeneklerini Belirleme ---
  recalculatePaymentOptions: () => {
    const { country, shippingMethod } = get();
    const isDomestic = country === 'TR';
    const codCompatible = ['yurtici', 'aras', 'mng'].includes(shippingMethod);

    const methods = ['creditCard']; // Kredi kartı her zaman kullanılabilir

    // Kapıda ödeme sadece yurt içi kargolarda geçerli
    if (isDomestic && codCompatible) {
      methods.push('cashOnDelivery');
    }

    const currentStillValid = methods.includes(get().paymentMethod);
    set({
      availablePaymentMethods: methods,
      paymentMethod: currentStillValid ? get().paymentMethod : methods[0],
    });
  },

  // --- YARDIMCI MANTIK: Kargo Ücretini Hesaplama ---
  recalculateShippingCost: () => {
    const { cartSubtotal } = get();
    const cost = cartSubtotal >= FREE_SHIPPING_THRESHOLD ? 0 : 29.9;
    set({ shippingCost: cost });
  },
}));

export default useCheckoutStore;

Buradaki kritik nokta şu: Store sadece state tutmuyor. Bileşenler arasındaki iş kurallarını ve koordinasyonu da yönetiyor.

Örneğin ülke değiştiğinde setCountry:

  1. Kargo seçeneklerini değiştiriyor

    SHIPPING_PROVIDERS[country] ile o ülkeye hizmet veren firmaları alıyor.

  2. Geçerli kargo seçimini kontrol ediyor

    Seçili kargo yeni ülkede yoksa listedeki ilkine düşüyor. Kullanıcı geçersiz bir seçimle kalmıyor.

  3. Ödeme seçeneklerini yeniden hesaplıyor

    Yurt dışına geçildiyse kapıda ödeme listeden düşüyor.

  4. Kargo ücretini yeniden hesaplıyor

    Ücretsiz kargo eşiği hala geçerli mi diye bakıyor.

Bunların hiçbirini CountrySelector bilmek zorunda değil.

Örnekteki applyCoupon neden production'a hazır değil?

applyCoupon, indirimi doğrudan cartSubtotal‘dan düşüyor:

cartSubtotal: state.cartSubtotal - discountAmount,

Bu, anlatımı kısaltıyor ama iki sorun taşıyor: aynı kuponu iki kez uygularsanız indirim iki kez düşer, ve kuponu kaldırmanın bir yolu yoktur — orijinal tutar kaybolmuştur.

Gerçek bir uygulamada ham veriyi bozmayıp indirimi ayrı tutmak gerekir:

cartSubtotal: 320,        // hiç değişmez
discount: 0,              // kupon bunu değiştirir

// türetilmiş değer:
const payableTotal = cartSubtotal - discount;

Kural şu: store’da yalnızca kaynak veriyi tut, gerisini türet. Bu, Mediator’a özel bir kural da değil; hesaplanabilir bir değeri state’e yazdığınız her yerde iki kaydı senkron tutma sorunu doğar.

2. Bileşenler

Şimdi component’lere bakalım. İlk olarak ülke seçimi:

// components/CountrySelector.jsx
function CountrySelector() {
  const country = useCheckoutStore((s) => s.country);
  const setCountry = useCheckoutStore((s) => s.setCountry);

  return (
    <select value={country} onChange={(e) => setCountry(e.target.value)}>
      <option value="TR">Türkiye</option>
      <option value="DE">Almanya</option>
      <option value="US">ABD</option>
    </select>
  );
}

Bu component’in bildiği tek şey:

setCountry(country);

Ülke değişince ne olacağını bilmiyor.

Kargo component’i de aynı şekilde sadece store ile konuşuyor:

// components/ShippingMethodPicker.jsx
function ShippingMethodPicker() {
  const methods = useCheckoutStore((s) => s.availableShippingMethods);
  const selected = useCheckoutStore((s) => s.shippingMethod);
  const setShippingMethod = useCheckoutStore((s) => s.setShippingMethod);

  return (
    <select value={selected} onChange={(e) => setShippingMethod(e.target.value)}>
      {methods.map((m) => (
        <option key={m} value={m}>{m}</option>
      ))}
    </select>
  );
}

Ödeme component’i ise kargo component’inin varlığından bile haberdar değil:

// components/PaymentMethodPicker.jsx
function PaymentMethodPicker() {
  const methods = useCheckoutStore((s) => s.availablePaymentMethods);
  const selected = useCheckoutStore((s) => s.paymentMethod);

  return (
    <div>
      {methods.map((m) => (
        <label key={m}>
          <input type="radio" checked={selected === m} readOnly /> {m}
        </label>
      ))}
      {/* Kapıda ödeme listede yoksa, kullanıcı yurt dışına geçtiğinde otomatik kaybolur */}
    </div>
  );
}

Burada güzel olan şey şu: PaymentMethodPicker, ülkenin değiştiğini bilmiyor. Kargo seçiminin değiştiğini de bilmiyor. Sadece store’daki availablePaymentMethods değiştiğinde UI kendini güncelliyor.

Store’u tek parça halinde değil, alan alan seçmek önemli. useCheckoutStore((s) => s.availablePaymentMethods) yazdığınızda component yalnızca o alan değiştiğinde render olur. useCheckoutStore() deyip her şeyi çekerseniz, kargo ücreti değiştiğinde ödeme component’i de boş yere render olur.

Kupon da aynı Mediator üzerinden ilerliyor

Kupon component’i de diğer component’lere dokunmuyor:

// components/CouponBox.jsx
function CouponBox() {
  const applyCoupon = useCheckoutStore((s) => s.applyCoupon);

  return (
    <button onClick={() => applyCoupon('WELCOME10', 50)}>
      "WELCOME10" Kuponunu Uygula
    </button>
  );
}

Kupon uygulandığında:

CouponBox
applyCoupon()
cartSubtotal değişir
recalculateShippingCost()
kargo ücreti yeniden hesaplanır

CouponBox, OrderSummary veya ShippingMethodPicker ile doğrudan konuşmaz.

Order Summary da sadece state’i okuyor

// components/OrderSummary.jsx
function OrderSummary() {
  const subtotal = useCheckoutStore((s) => s.cartSubtotal);
  const shippingCost = useCheckoutStore((s) => s.shippingCost);

  return (
    <div>
      <p>Ara toplam: {subtotal} TL</p>
      <p>Kargo: {shippingCost === 0 ? 'Ücretsiz' : `${shippingCost} TL`}</p>
      <p><strong>Toplam: {subtotal + shippingCost} TL</strong></p>
    </div>
  );
}

Bu component’in de herhangi bir business rule bilmesine gerek yok. Sadece kendisine gereken state’i okuyor.

Mimari kabaca böyle

graph LR
    C[CountrySelector] --> M{{Zustand Mediator}}
    S[ShippingMethodPicker] --> M
    P[PaymentMethodPicker] --> M
    K[CouponBox] --> M
    O[OrderSummary] --> M

    M --> C
    M --> S
    M --> P
    M --> O

Bütün iletişim merkezde toplanıyor. Component’ler birbirlerine bağlanmıyor.

Neden Mediator kullanmalıyız?

Buradaki asıl problem state’in kendisi değil, state’ler arasındaki ilişkiler. Bizim sistemimizde iki önemli zincir var:

Ülke zinciri

Ülke
Kargo seçenekleri
Ödeme seçenekleri

Kupon zinciri

Kupon
Ara toplam
Ücretsiz kargo kontrolü
Kargo ücreti

Bu kuralları component’lerin içine dağıtmaya başlarsanız işler kısa sürede karışır. Örneğin CountrySelector içinde şunlar olmaya başlar:

if (country === 'TR') {
  // kargoları güncelle
  // ödeme seçeneklerini güncelle
  // seçili kargoyu kontrol et
  // seçili ödemeyi kontrol et
}

Sonra aynı kurallar başka component’lerde de tekrar etmeye başlar. Bir süre sonra CheckoutPage bütün bu kuralların merkezine dönüşür. Sonuç genellikle şuna benzer:

CheckoutPage
 ├── CountrySelector
 ├── ShippingMethodPicker
 ├── PaymentMethodPicker
 ├── CouponBox
 └── OrderSummary

CheckoutPage:
 ├── country state
 ├── shipping state
 ├── payment state
 ├── coupon state
 ├── callback'ler
 ├── useEffect'ler
 └── bir sürü business logic

İşte Mediator burada devreye giriyor. Business logic’i component’lerden çıkarıp merkezi bir koordinasyon katmanına taşıyoruz.

Mediator'ın kendi tuzağı

Bu desenin bilinen bedeli şu: koordinasyonu tek yere topladığınız için o yer büyür. Checkout’a iade, hediye paketi, fatura adresi ve taksit seçenekleri de eklendiğinde store bir “god object"e dönüşebilir.

Panzehiri Mediator’dan vazgeçmek değil, her ekrana kendi Mediator’ını vermek. Tek bir dev useAppStore yerine useCheckoutStore, useCartStore, useProfileStore gibi ekran/akış bazlı store’lar tutun. Mediator’ın sınırı, koordine ettiği ekranın sınırıdır.

Mediator’ın en büyük avantajı: test edilebilirlik

Bence bu yaklaşımın en güzel taraflarından biri bu. Business logic’i test etmek için component render etmemiz gerekmiyor. DOM ile uğraşmıyoruz. Browser’a ihtiyacımız yok.

Doğrudan store’un davranışını test edebiliriz:

import useCheckoutStore from './useCheckoutStore';

test('ABD seçilince kapıda ödeme kalkar', () => {
  useCheckoutStore.getState().setCountry('US');

  const availableMethods =
    useCheckoutStore.getState().availablePaymentMethods;

  expect(availableMethods).not.toContain('cashOnDelivery');
});

test('Kupon toplamı 150 TL altına düşürünce kargo tekrar ücretli olur', () => {
  useCheckoutStore.setState({ cartSubtotal: 200 });

  // 200 - 80 = 120
  // Ücretsiz kargo eşiğinin altına düştü
  useCheckoutStore.getState().applyCoupon('BIG50', 80);

  expect(useCheckoutStore.getState().shippingCost).toBe(29.9);
});

Bu testlerde React component’i yok. Çünkü test ettiğimiz şey UI değil, koordinasyon mantığı.

Store testler arasında paylaşılır

Zustand store’u modül seviyesinde tek bir örnektir. Yukarıdaki ilk test ülkeyi US yaptığında, ikinci test de US ile başlar. Bugün zarar vermiyor ama testlerin sırası değiştiğinde açıklanamayan hatalar üretir.

Her testten önce store’u sıfırlayın:

const initialState = useCheckoutStore.getState();

beforeEach(() => {
  useCheckoutStore.setState(initialState, true);
});

İkinci parametre olan true, merge yerine tam değiştirme yapar.

Ne zaman Mediator düşünmeliyiz?

Her component grubuna Mediator koymak gerekmiyor. İki component arasında basit bir state paylaşımı varsa Context veya props gayet yeterli olabilir.

DurumYaklaşım
Tek component’in kendi state’iuseState
Parent’tan child’a tek yönlü veriprops
Uzaktaki birkaç component aynı değeri okuyor, kimse kimseyi tetiklemiyorContext
Bir state değişimi başka state’leri de değiştiriyorMediator

Mediator yaklaşımı özellikle şu durumda değer kazanmaya başlar:

  • Birden fazla component birbirini etkiliyorsa.
  • Bir state değişikliği başka state’leri de güncelliyorsa.
  • Aynı business rule birden fazla component’te tekrar etmeye başladıysa.
  • useEffect zincirleri çoğalıyorsa.
  • Parent component bütün child component’lerin iç işleyişini bilmeye başladıysa.
  • Prop drilling ve callback zincirleri okunabilirliği bozmaya başladıysa.

Bunlar genellikle “burada bir koordinasyon katmanına ihtiyacımız var” sinyalidir.

Altta yatan fikir

Mediator Pattern’in özü aslında oldukça basit:

Component’ler birbirleriyle konuşmasın. Birbirleriyle ilgili kuralları Mediator yönetsin.

Bizim örnekte:

CountrySelector
   Mediator
Shipping + Payment

ve:

CouponBox
   Mediator
Subtotal + Shipping

Böylece component’ler daha küçük ve bağımsız kalıyor. Yeni bir iş kuralı geldiğinde de ilk bakacağınız yer belli oluyor: Mediator.

Frontend’de karmaşıklık çoğu zaman component sayısından değil, component’lerin birbirleriyle kurduğu bağlantıların sayısından gelir. Mediator’ın yaptığı şey de tam olarak bu bağlantıları azaltmak.

Component’ler birbirini tanımasın; koordinasyonu tek bir yer yönetsin.