GUIDE · Behavioral
Mediator
Define an object that encapsulates how a set of objects interact; promotes loose coupling by keeping objects from referring to each other explicitly.
At a glance
- INTENT
- Define an object that encapsulates how a set of objects interact; promotes loose coupling by keeping objects from referring to each other explicitly.
- PROBLEM
- A checkout form: country select resets the region select, zip validates against country, submit enables only when all valid — N widgets each holding references to the others is a hairball.
- SAY
- Participants: Mediator (interface: notify(sender, event)), ConcreteMediator (knows all colleagues, owns the coordination rules), Colleague (talks only to the mediator). Pitch: "hub and spoke — peers never reference each other; all traffic goes through the coordinator."
- SEEN IN
- React "lift state up" — a parent coordinating sibling components via props/callbacks; Flux Dispatcher / Redux store as the hub components dispatch into; Socket.io server & rooms (clients never talk peer-to-peer); react-hook-form
controlcoordinating registered fields; chat servers. - VS
- Observer = one Subject broadcasting to anonymous listeners; Mediator = many colleagues talking THROUGH one object that holds the LOGIC (often built with Observer underneath). Facade is one-way simplification of a subsystem; Mediator is two-way coordination among peers.
The idea#
Picture a group chat. Ada wants Bob and Cy to hear "deploy at 5?" She does not open Bob's phone, then Cy's phone, then check who already left. She talks to the room. The room knows who is still in it and drops the line in every other inbox.
That room is a mediator. The people are colleagues. Each one holds a single handle, the room, and nobody holds a handle to anybody else. A colleague reports what just happened. The room decides what else has to change.
A checkout form is the same chat with worse wiring if you skip the room. Country, region, zip, and the submit button all care about each other. Pick a country and the region list has to reset. Type a zip and it has to match that country. Submit stays dark until the rest is valid. If every widget stores the other three, you get a hairball. One new field means every old field grows a new wire, the way a group chat falls apart when everyone starts texting each other on the side.
Put one coordinator in the middle. A widget calls notify with itself and an event name. The coordinator is the only object that knows the rules: US regions are CA and NY, a US zip is five digits, submit turns on only when country, region, and zip are filled and the zip has no error.
The book names are Mediator, ConcreteMediator, and Colleague. The mediator's job is notify(sender, event). The concrete mediator knows every colleague and owns the coordination rules. A colleague talks only to the mediator.
Why it matters#
The bug this pattern prevents is a rule that lives in two places and drifts. Country clears the zip. Zip also clears itself when it feels like it. Submit enables itself from a third copy of the same check. A year later those copies disagree, and a Canadian postal code gets judged with a US pattern. In the chat picture, that is three people forwarding the same news and one of them forwarding an old version.
You already sit in this shape. When you lift state up, the parent is the room and the children are the colleagues. They call back. They do not call across. A chat server is the same hub: clients never open a socket to each other. They send to the server, and the server fans the message out. A store that components dispatch into works the same way. The rules sit in one place, not in every button.
Observer is the pattern people reach for and then regret. An observer subject holds a list and says "I changed." The listeners are anonymous. The subject does not know that a country change should wipe a zip code. That decision is logic, and the logic belongs in the mediator. You can build a mediator out of observers, the way a chat room is built out of inboxes. The jobs stay different. Observer broadcasts a fact. Mediator coordinates peers. A facade is a different neighbor: one front door, one direction, hiding a subsystem behind a simpler call. A mediator is two-way. Events come in from colleagues, and changes go back out to other colleagues.
A small example#
function createRoom() { const members = new Map(); return { join(name) { const inbox = []; members.set(name, inbox); return { inbox, say(text) { for (const [n, box] of members) { if (n !== name) box.push(`${name}: ${text}`); } }, }; }, };} const room = createRoom();const ada = room.join('ada');const bob = room.join('bob');ada.say('hi');Ada never touches Bob's inbox. The room does. After ada.say('hi'), Bob's inbox holds "ada: hi" and Ada's inbox stays empty, because the room skips the sender.
What the code below is doing#
The sheet shows the same idea twice.
The first shape uses classes so the names are visible. Widget is the colleague. It stores a mediator, and changed calls notify. Select, TextInput, and Button are the people in the room, and the room is a form. CheckoutFormMediator points every widget at itself and starts with region disabled. notify is the rulebook. A country select loads that country's regions, enables region when the list is not empty, and clears the zip. A zip input checks the postal pattern for the current country. A submit click, only if the button is enabled, records country, region, and zip. After every event, submit turns on only when all three are filled and the zip has no error.
The asserts walk one checkout. Region starts off. Selecting US sets the options to CA and NY. Zip 1234 errors and leaves submit off. 10001 clears the error and turns submit on. The click records one row. Switching to CA nulls the region, blanks the zip, and disables submit. A click on that disabled button does not count. Country never called region. They only talked to the room.
The second shape is the group chat as a function. join hands back an inbox, say, dm, and leave. say writes every inbox except the sender. dm throws if the name is gone. leave drops the member. Ada's line lands in Bob's inbox and Cy's, not her own. Bob's direct message lands only with Ada. After Cy leaves, later lines miss Cy, a direct message to Cy throws, and the room size is 2.
When you talk about it, say this: hub and spoke. Peers never reference each other. All the traffic goes through the coordinator.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Classic shape (classes, so the participant names are visible)#
class Widget { // Colleague base — knows only its mediator constructor(name) { this.name = name; this.mediator = null; this.enabled = true; } changed(event, payload) { this.mediator?.notify(this, event, payload); }}class Select extends Widget { constructor(name, options = []) { super(name); this.options = options; this.value = null; } setOptions(opts) { this.options = opts; this.value = null; } select(v) { this.value = v; this.changed('select', v); }}class TextInput extends Widget { constructor(name) { super(name); this.value = ''; this.error = null; } type(v) { this.value = v; this.changed('input', v); }}class Button extends Widget { constructor(name) { super(name); this.enabled = false; this.clicks = 0; } click() { if (this.enabled) { this.clicks += 1; this.changed('click'); } }} class CheckoutFormMediator { // ConcreteMediator — ALL cross-widget rules live here static regions = { US: ['CA', 'NY'], CA: ['ON', 'BC'] }; static zipRe = { US: /^\d{5}$/, CA: /^[A-Z]\d[A-Z] ?\d[A-Z]\d$/ }; constructor({ country, region, zip, submit }) { Object.assign(this, { country, region, zip, submit, submitted: [] }); for (const w of [country, region, zip, submit]) w.mediator = this; region.enabled = false; } notify(sender, event, payload) { if (sender === this.country && event === 'select') { this.region.setOptions(CheckoutFormMediator.regions[payload] ?? []); this.region.enabled = this.region.options.length > 0; this.zip.value = ''; this.zip.error = null; } if (sender === this.zip && event === 'input') { const re = CheckoutFormMediator.zipRe[this.country.value]; this.zip.error = re && !re.test(payload) ? `invalid postal code for ${this.country.value}` : null; } if (sender === this.submit && event === 'click') { this.submitted.push({ country: this.country.value, region: this.region.value, zip: this.zip.value }); } this.submit.enabled = Boolean(this.country.value && this.region.value && this.zip.value && !this.zip.error); }} const country = new Select('country', ['US', 'CA']);const region = new Select('region');const zip = new TextInput('zip');const submit = new Button('submit');const form = new CheckoutFormMediator({ country, region, zip, submit }); country.select('US');region.select('NY');zip.type('1234');zip.type('10001');submit.click();country.select('CA'); // switching country resets dependentssubmit.click();Verified by 12 asserts
assert.equal(region.enabled, false);assert.deepEqual(region.options, ['CA', 'NY']); // country reconfigured region — they never metassert.equal(region.enabled, true);assert.equal(zip.error, 'invalid postal code for US');assert.equal(submit.enabled, false);assert.equal(zip.error, null);assert.equal(submit.enabled, true);assert.deepEqual(form.submitted, [{ country: 'US', region: 'NY', zip: '10001' }]);assert.equal(region.value, null);assert.equal(zip.value, '');assert.equal(submit.enabled, false);assert.equal(submit.clicks, 1); // disabled button ignored the clickIdiomatic JS shape: a chat room#
participants hold a handle to the room, never to each other
const createChatRoom = () => { const members = new Map(); // name → inbox return { join(name) { const inbox = []; members.set(name, inbox); return { inbox, say: (text) => { for (const [n, box] of members) if (n !== name) box.push(`${name}: ${text}`); }, dm: (to, text) => { if (!members.has(to)) throw new Error(`${to} not in room`); members.get(to).push(`${name} (dm): ${text}`); }, leave: () => members.delete(name), }; }, get size() { return members.size; }, };};const room = createChatRoom();const ada = room.join('ada');const bob = room.join('bob');const cy = room.join('cy');ada.say('deploy at 5?');bob.dm('ada', 'yes');cy.leave();bob.say('ok');Verified by 6 asserts
assert.deepEqual(bob.inbox, ['ada: deploy at 5?']);assert.deepEqual(cy.inbox, ['ada: deploy at 5?']);assert.deepEqual(ada.inbox, ['bob (dm): yes']); // sender doesn't receive own broadcastassert.equal(cy.inbox.length, 1); // departed members stop receivingassert.throws(() => ada.dm('cy', 'x'), /cy not in room/);assert.equal(room.size, 2);See also