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:
Kargo seçeneklerini değiştiriyor
SHIPPING_PROVIDERS[country]ile o ülkeye hizmet veren firmaları alıyor.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.
Ödeme seçeneklerini yeniden hesaplıyor
Yurt dışına geçildiyse kapıda ödeme listeden düşüyor.
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 --> OBü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.
| Durum | Yaklaşım |
|---|---|
| Tek component’in kendi state’i | useState |
| Parent’tan child’a tek yönlü veri | props |
| Uzaktaki birkaç component aynı değeri okuyor, kimse kimseyi tetiklemiyor | Context |
| Bir state değişimi başka state’leri de değiştiriyor | Mediator |
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.
useEffectzincirleri ç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.

Yorumlar