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:

  1. Changes the shipping options

    It reads the couriers that serve that country from SHIPPING_PROVIDERS[country].

  2. 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.

  3. Recalculates the payment options

    If the user moved abroad, cash on delivery drops off the list.

  4. 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 --> O

All 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.

SituationApproach
State belongs to one componentuseState
One-way data from parent to childprops
A few distant components read the same value, nobody triggers anybodyContext
One state change also changes other stateMediator

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.
  • useEffect chains 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.