Some frontend screens look simple at the start, but get messy fast as new features arrive. A checkout page is one of the best examples.
On an e-commerce site, the country changes the shipping options, the shipping option changes the payment methods, a coupon changes the total, and the total changes whether shipping is free. So the components keep affecting each other all the time.
Here is the problem: once you start wiring every component to every other component, you end up with prop drilling, long callback chains, and components that know about each other.
Instead, we can put a Mediator in the middle. In React, a central store like Zustand or Redux can do that job. Components no longer talk to each other. They only talk to the Mediator.
A real scenario: checkout
Think of the payment page of an e-commerce platform like Amazon or Shopify. The screen has roughly these components:
CountrySelector
↓
ShippingMethodPicker
↓
PaymentMethodPicker
CouponBox
↓
OrderSummary
At first they look independent. But the real business rules are a bit more complex:
- If the country changes: the shipping companies that serve that country change. Yurtiçi Kargo works in Turkey, but not in Germany.
- If the shipping method changes: the shipping cost changes, and some payment methods may become unavailable. Cash on delivery only works with certain domestic couriers.
- If a coupon is applied: the subtotal drops. If the total falls below the free shipping limit, shipping is no longer free.
So what we really have is a coordination problem.
The important point here: components do not need to know about each other. CountrySelector should not even know that PaymentMethodPicker exists.
What does the Mediator do?
The main idea of the Mediator Pattern is simple:
Instead of letting components talk to each other, put a middleman between them.
In our example that middleman is the Zustand store. The component only says:
"The country changed."
And then:
Mediator
↓
Update shipping options
↓
Update payment options
↓
Fix the selected values if needed
The component does not need to know the rest of that chain.
You can see the difference most clearly like this:
// CheckoutPage becomes the center of every rule
<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);
}}
/>
// The component says one thing, the Mediator does the rest
<CountrySelector />
// inside it:
setCountry(country);
1. The Mediator — a Zustand store
We put all the coordination in one place:
// 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. COORDINATION: country change ---
setCountry: (country) => {
const methods = SHIPPING_PROVIDERS[country] ?? [];
const currentStillValid = methods.includes(get().shippingMethod);
set({
country,
availableShippingMethods: methods,
// If the selected courier is not available in the new country, pick the first one
shippingMethod: currentStillValid ? get().shippingMethod : methods[0],
});
// Trigger the follow-up effects
get().recalculatePaymentOptions();
get().recalculateShippingCost();
},
// --- 2. COORDINATION: shipping method change ---
setShippingMethod: (method) => {
set({ shippingMethod: method });
// Trigger the follow-up effects
get().recalculateShippingCost();
get().recalculatePaymentOptions();
},
// --- 3. COORDINATION: applying a coupon ---
applyCoupon: (code, discountAmount) => {
set((state) => ({
couponCode: code,
cartSubtotal: state.cartSubtotal - discountAmount,
}));
// A coupon can affect the free shipping limit, so recalculate
get().recalculateShippingCost();
},
// --- HELPER LOGIC: deciding the payment options ---
recalculatePaymentOptions: () => {
const { country, shippingMethod } = get();
const isDomestic = country === 'TR';
const codCompatible = ['yurtici', 'aras', 'mng'].includes(shippingMethod);
const methods = ['creditCard']; // Credit card is always available
// Cash on delivery only works with domestic couriers
if (isDomestic && codCompatible) {
methods.push('cashOnDelivery');
}
const currentStillValid = methods.includes(get().paymentMethod);
set({
availablePaymentMethods: methods,
paymentMethod: currentStillValid ? get().paymentMethod : methods[0],
});
},
// --- HELPER LOGIC: calculating the shipping cost ---
recalculateShippingCost: () => {
const { cartSubtotal } = get();
const cost = cartSubtotal >= FREE_SHIPPING_THRESHOLD ? 0 : 29.9;
set({ shippingCost: cost });
},
}));
export default useCheckoutStore;
The key point here: the store does not only hold state. It also owns the business rules and the coordination between components.
For example, when the country changes, setCountry:
Changes the shipping options
It reads the couriers that serve that country from
SHIPPING_PROVIDERS[country].Checks the current shipping choice
If the selected courier is not available in the new country, it falls back to the first one. The user is never left with an invalid choice.
Recalculates the payment options
If the user moved abroad, cash on delivery drops off the list.
Recalculates the shipping cost
It checks whether the free shipping limit still applies.
CountrySelector does not need to know any of this.
Why the applyCoupon in this example is not production ready
applyCoupon subtracts the discount straight from cartSubtotal:
cartSubtotal: state.cartSubtotal - discountAmount,
This keeps the example short, but it has two problems. If you apply the same coupon twice, the discount is subtracted twice. And there is no way to remove the coupon, because the original amount is gone.
In a real app you keep the raw data as it is and store the discount separately:
cartSubtotal: 320, // never changes
discount: 0, // the coupon changes this
// derived value:
const payableTotal = cartSubtotal - discount;
The rule is simple: keep only the source data in the store and derive the rest. This is not specific to the Mediator either. Any time you write a value into state that could be calculated, you now have two copies to keep in sync.
2. The components
Now let’s look at the components. First, the country selector:
// 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">Turkey</option>
<option value="DE">Germany</option>
<option value="US">USA</option>
</select>
);
}
The only thing this component knows is:
setCountry(country);
It does not know what happens after the country changes.
The shipping component also talks only to the store:
// 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>
);
}
And the payment component does not even know that the shipping component exists:
// 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>
))}
{/* If cash on delivery is not in the list, it disappears when the user goes abroad */}
</div>
);
}
Here is the nice part: PaymentMethodPicker does not know that the country changed. It does not know that the shipping choice changed either. It just updates itself when availablePaymentMethods changes in the store.
It matters that you select fields one by one instead of taking the whole store. When you write useCheckoutStore((s) => s.availablePaymentMethods), the component only re-renders when that field changes. If you write useCheckoutStore() and take everything, the payment component also re-renders when the shipping cost changes, for nothing.
The coupon goes through the same Mediator
The coupon component does not touch the other components either:
// components/CouponBox.jsx
function CouponBox() {
const applyCoupon = useCheckoutStore((s) => s.applyCoupon);
return (
<button onClick={() => applyCoupon('WELCOME10', 50)}>
Apply the "WELCOME10" coupon
</button>
);
}
When the coupon is applied:
CouponBox
↓
applyCoupon()
↓
cartSubtotal changes
↓
recalculateShippingCost()
↓
the shipping cost is calculated again
CouponBox never talks to OrderSummary or ShippingMethodPicker directly.
Order Summary only reads state
// components/OrderSummary.jsx
function OrderSummary() {
const subtotal = useCheckoutStore((s) => s.cartSubtotal);
const shippingCost = useCheckoutStore((s) => s.shippingCost);
return (
<div>
<p>Subtotal: {subtotal} TL</p>
<p>Shipping: {shippingCost === 0 ? 'Free' : `${shippingCost} TL`}</p>
<p><strong>Total: {subtotal + shippingCost} TL</strong></p>
</div>
);
}
This component does not need to know any business rule. It just reads the state it needs.
The architecture, roughly
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 --> OAll communication meets in the center. The components are not wired to each other.
Why use a Mediator?
The real problem here is not the state itself. It is the relations between the pieces of state. Our system has two important chains:
The country chain
Country
↓
Shipping options
↓
Payment options
The coupon chain
Coupon
↓
Subtotal
↓
Free shipping check
↓
Shipping cost
If you spread these rules across the components, things get messy quickly. For example, CountrySelector starts to look like this:
if (country === 'TR') {
// update the couriers
// update the payment options
// check the selected courier
// check the selected payment method
}
Then the same rules start to repeat in other components too. After a while CheckoutPage becomes the center of all of them. The result usually looks like this:
CheckoutPage
├── CountrySelector
├── ShippingMethodPicker
├── PaymentMethodPicker
├── CouponBox
└── OrderSummary
CheckoutPage:
├── country state
├── shipping state
├── payment state
├── coupon state
├── callbacks
├── useEffects
└── a lot of business logic
This is where the Mediator comes in. We take the business logic out of the components and move it into one coordination layer.
The Mediator has its own trap
This pattern has a known cost: because you collect all the coordination in one place, that place grows. Once checkout also gets returns, gift wrapping, billing address and instalment options, the store can turn into a god object.
The answer is not to drop the Mediator. It is to give each screen its own Mediator. Instead of one huge useAppStore, keep stores per screen or per flow: useCheckoutStore, useCartStore, useProfileStore. The border of a Mediator is the border of the screen it coordinates.
The biggest win: testability
For me this is one of the nicest parts of the approach. We do not have to render a component to test the business logic. No DOM. No browser.
We can test the behaviour of the store directly:
import useCheckoutStore from './useCheckoutStore';
test('cash on delivery disappears when USA is selected', () => {
useCheckoutStore.getState().setCountry('US');
const availableMethods =
useCheckoutStore.getState().availablePaymentMethods;
expect(availableMethods).not.toContain('cashOnDelivery');
});
test('shipping is charged again when a coupon drops the total below 150 TL', () => {
useCheckoutStore.setState({ cartSubtotal: 200 });
// 200 - 80 = 120
// Below the free shipping limit
useCheckoutStore.getState().applyCoupon('BIG50', 80);
expect(useCheckoutStore.getState().shippingCost).toBe(29.9);
});
There is no React component in these tests, because what we test is not the UI. It is the coordination logic.
The store is shared between tests
A Zustand store is a single instance at module level. The first test above sets the country to US, so the second test starts with US too. It does no harm today, but it will produce confusing failures as soon as the test order changes.
Reset the store before each test:
const initialState = useCheckoutStore.getState();
beforeEach(() => {
useCheckoutStore.setState(initialState, true);
});
The second argument, true, replaces the state instead of merging into it.
When should you reach for a Mediator?
You do not need a Mediator for every group of components. If two components just share a simple value, Context or props are fine.
| Situation | Approach |
|---|---|
| State belongs to one component | useState |
| One-way data from parent to child | props |
| A few distant components read the same value, nobody triggers anybody | Context |
| One state change also changes other state | Mediator |
The Mediator approach starts to pay off when:
- More than one component affects the others.
- One state change also updates other state.
- The same business rule starts to repeat in more than one component.
useEffectchains keep growing.- The parent component starts to know how every child works inside.
- Prop drilling and callback chains start to hurt readability.
These are usually the signal that says “we need a coordination layer here”.
The idea underneath
The core of the Mediator Pattern is quite simple:
Components should not talk to each other. The Mediator should own the rules about how they relate.
In our example:
CountrySelector
↓
Mediator
↓
Shipping + Payment
and:
CouponBox
↓
Mediator
↓
Subtotal + Shipping
This keeps the components small and independent. And when a new business rule arrives, you know where to look first: the Mediator.
In frontend work, complexity usually does not come from the number of components. It comes from the number of connections between them. Cutting those connections is exactly what the Mediator does.
Components should not know each other. One place should own the coordination.

Comments