← Design patterns
Behavior Coordination between collaborators

Mediator

Give collaboration a home.

Click a block in an editor. The preview highlights it, the inspector shows its title, and the toolbar decides whether Rename is available. One action has become a rule about several participants.

Give that rule an explicit owner, so each control can describe an intent without knowing how every pane must respond.

TypeScriptGoPython Same intents and pane deliveries · across the comparison

01 / A useful direct call grows into a collaboration rule

Which pane is responsible for the other panes?

With a preview and an inspector, letting a click handler update both can be perfectly clear. Add a toolbar, read-only blocks, clearing selection, and edits that originate in the inspector. Now several handlers must remember the same relationships.

A preview that calls the inspector and toolbar directly knows more than how to display a block. An inspector that renames a block and refreshes its siblings now knows the collaboration too. Adding another participant means finding every place that needs to involve it.

A Mediator owns the rules for how a set of participants work together. Intent reaches it through the handlers setup gives each control: a click in the preview, a submit in the inspector, a clear from the toolbar. It decides which collaborators to involve and what values to supply, and the pane objects only receive those values. They can use narrow interfaces without holding references to one another—or, unlike GoF’s colleagues, to the mediator itself, so no pane depends on the coordinator.

The dependencies have a new home; they have not disappeared. The mediator deliberately knows this workspace’s interaction rules. It should be small enough that you can explain those rules together.

Control handler → intent

Say what the user requested.

A click in the preview selects a block. A submit in the inspector sends a title for a captured block ID. The toolbar’s clear button clears selection. The pane objects only render what the coordinator sends them.

Mediator → collaboration

Keep the workspace coherent.

Own the current selection, check an edit’s target, and send accepted state to the three panes.

Document → rules

Protect the content.

The document decides whether a block can be renamed and whether the proposed title is valid.

02 / Name the incoming intents and outgoing updates

Selection is one decision with three destinations.

The basic view excerpts select and publish from the complete mediator. An accepted selection sends a value to Preview and Inspector, then sends the selected block’s rename policy to Toolbar. Each pane receives its own value; changing a pane’s copy cannot edit the document.

The practical view adds the document, narrow output ports, input actions, editing, and closure. The document contains Introduction, a read-only Cover, and Next steps. Exact IDs are intro, cover, and outro. Unknown IDs leave selection unchanged.

A rename includes its target ID. The coordinator checks that the workspace is open, that a block is selected, and that the target still matches. The document then checks editability and a nonempty title, or reports no change when the title already matches. Text is preserved exactly, whitespace included.

The selection and publication methods from the full mediator. One accepted selection supplies values to Preview, Inspector, and Toolbar. These are source excerpts; the practical view supplies the ports and document.

TypeScriptReading
workspace.ts
select(id: string): Outcome {
	this.delivered = [];
	if (!this.active) return 'closed';
	if (!this.document.find(id)) return 'unknown-block';
	if (id === this.selected) return 'unchanged';
	this.selected = id;
	this.publish();
	return 'updated';
}
private publish(): void {
	const block = this.selected === null ? null : this.document.find(this.selected);
	// Separate values for each pane. show() must not emit a new user intent.
	this.preview.show(block ? { ...block } : null);
	this.delivered.push('preview.show');
	this.inspector.show(block ? { ...block } : null);
	this.delivered.push('inspector.show');
	this.toolbar.setRenameEnabled(block?.editable ?? false);
	this.delivered.push('toolbar.setRenameEnabled');
}
GoAlongside
workspace.go
func (m *EditorMediator) Select(id string) Outcome {
	m.delivered = nil
	if !m.active {
		return Closed
	}
	if m.document.Find(id) == nil {
		return UnknownBlock
	}
	if id == m.selected {
		return Unchanged
	}
	m.selected = id
	m.publish()
	return Updated
}
func (m *EditorMediator) publish() {
	block := m.document.Find(m.selected)
	// Show updates silently. It must not emit a new user intent.
	m.preview.Show(copyBlock(block))
	m.delivered = append(m.delivered, "preview.show")
	m.inspector.Show(copyBlock(block))
	m.delivered = append(m.delivered, "inspector.show")
	m.toolbar.SetRenameEnabled(block != nil && block.Editable)
	m.delivered = append(m.delivered, "toolbar.setRenameEnabled")
}
Reading the TypeScriptInterfaces describe each direction of communication

EditorActions is the incoming intent interface. SelectionPort and ToolbarPort describe what the coordinator can ask the panes to do. Ordinary objects can implement these contracts; no event registry or base class is required.

The setup callbacks capture the coordinator, while the pane objects store only rendered values. The document, snapshots, and each selection delivery copy their flat block data. TypeScript’s private fields express an authoring boundary, not a runtime security boundary.

Reading the PythonProtocols, copies, and convention-based privacy

Python expresses the pane contracts as Protocols, so a small object with the right method can participate without inheriting from a base class. The mediator stores those ports and sends a fresh Block to each one. The underscore-prefixed fields communicate ownership by convention; Python does not make them private to the runtime.

The document returns copies from find and snapshot, while the mediator keeps the selected ID and delivery order. A pane update is a render call, not a new intent, so these memory adapters do not call back into the coordinator.

Reading the GoInterface fields and pointer-owned collaborators

The coordinator contains two SelectionPort interfaces and a ToolbarPort. Preview and Inspector can share an interface while being separate objects. Pointer receivers retain the mediator’s selection and the pane update counts across calls.

Find and Snapshot return copies of the document’s block values, and each pane receives a separate block pointer. Construct the coordinator with NewMediator and valid collaborators. The memory implementations run on one goroutine at a time; these interfaces add no locking.

03 / Predict, act, and inspect the deliveries

Follow one intent across the workspace.

Select Introduction and apply a new title. Both panes should show it, and Rename should remain available. Before selecting Cover, predict what will happen to the inspector and toolbar.

Then open the held-edit experiment. Capture a title edit for Introduction, select another block, and deliver the old intent. Its ID belongs to the edit that was created earlier; it must not silently become the new selection’s ID.

Keep three panes in agreement

Select Introduction, then Cover. Predict which pane values change and whether Rename remains available. Try an edit, then inspect the coordinator’s deliveries.

  1. 01 / user intentPreview, Inspector, or Toolbar

    Select a block, submit a title, or clear selection.

  2. 02 / coordinationWorkspace mediator

    Check the target, ask the document, then update the panes.

  3. 03 / silent updatesEach pane renders its value

    Receiving a value does not send another user intent.

Workspace open · Selected ID: none

Preview

1 update

A click sends a selection intent.

Showing: No selection

Inspector

1 update

The pane receives a selection value and keeps a local title draft.

Editing ID: none

Select a block to edit its title.

Toolbar

1 update

The mediator supplies the selected block’s rename policy.

Rename policy: unavailable

Apply submits the Inspector’s target ID and draft. Clear sends its own intent.

ready

Workspace opened. All three panes received the empty selection.

Pane deliveries for this action

  1. preview.show
  2. inspector.show
  3. toolbar.setRenameEnabled
What if an old Inspector edit arrives later?

Select Introduction and hold an edit. Select another block, then deliver it. Its captured ID stays Introduction. The hold control intentionally allows testing a read-only target too.

No held edit.

The ID check prevents an edit from crossing to a different selection.

A held edit can still be delivered after Close to observe rejection. Reset constructs a new workspace and discards the held edit.

Recent intents · up to 6

No user intents yet.

This lab runs the displayed TypeScript with three memory-backed panes. The held edit stands in for a late request that you deliver by hand. Python, Java, Go, and Rust are verified separately against the same contract.

04 / An update is not automatically another request

Keep the input and rendering paths distinct.

The pane ports in this example update silently. A user click can request a selection, but receiving that selection does not manufacture another click. This distinction matters when integrating a widget that also emits change callbacks during programmatic updates.

A render update has started looking like another user action.

A new Inspector widget calls its change handler when the mediator supplies a selection. The handler sends that selection back to the mediator. Which boundary should the integration establish?

Reasoning and a stopping pointAlso available without JavaScript

The intended direction is user intent → coordinator → pane update. Programmatic show methods are silent. If a widget also fires callbacks on programmatic writes, its adapter must distinguish those writes from user actions.

Suppressing an unchanged value helps, but a real integration must still define callback behavior and, when needed, queue work deliberately rather than recursively entering the coordinator.

If two views only display the same count, a shared or derived value may be enough. A mediator earns its place when it owns a specific rule about how participants respond together.

Which interaction rule belongs to the coordinator? Which validation stays in the document? What may a pane do when it receives an update?

This reflection is not saved or automatically assessed.

05 / Central coordination has a deliberate limit

The document still knows what a valid edit is.

The mediator owns the selection and its relationship to an inspector submission. It asks the document to perform the rename. Read-only and title rules remain in the document so another legitimate caller cannot bypass them simply by skipping this coordinator.

The toolbar’s disabled button communicates a policy to the user. It does not enforce the document’s rule. The held-edit control can bypass that UI affordance: the document still refuses a Cover edit.

Unchanged actions produce no pane deliveries. Rejected actions also preserve the document and pane values. A successful selection, rename, or clear supplies all three panes in a documented order. Their update counters count method calls, including initial setup.

A matching target ID is not a revision checkWhat the stale-edit result establishes

The stale-edit result means the captured target differs from the current selection. If you select away and back before delivering an old edit, its ID matches again. An older edit for the same block can also overwrite a newer title. The sample deliberately has no revision or request-generation check.

A collaborative editor may need a document version, edit token, merge policy, or explicit conflict result. Keep that decision visible.

Three updates are not a transactionThe sample assumes local, silent, successful pane methods

Publication invokes Preview, Inspector, and Toolbar sequentially. The final values agree when those calls return normally. The sample does not roll back the document or already updated panes if an adapter throws or panics. It also assumes a pane update never calls back into the coordinator; this sample has no re-entrancy guard. In our TypeScript and Go runs, a Preview that selected Next steps from inside its update for Cover left Preview showing Next steps while Inspector showed Cover and Rename stayed unavailable. A Preview that selected a different block every time it was shown never returned: TypeScript threw “RangeError: Maximum call stack size exceeded”, and Go stopped with “goroutine stack exceeds 1000000000-byte limit”.

With fallible adapters, decide whether to derive views from a committed state, report partial delivery, retry rendering, or rebuild an affected view. Adding a try/catch without choosing one of those policies does not make publication atomic.

06 / Own one workspace, not every interaction in the application

Setup connects the participants and owns their lifetime.

At workspace creation, setup creates a document, pane adapters, and one coordinator. It supplies selection, rename, and clear callbacks to the relevant controls. Each handler carries intent; each output port translates a supplied value into a pane update.

Closing the workspace stops future intents from applying changes. The document and last snapshots stay available for inspection. Real setup also owns detaching listeners, subscriptions, and widget callbacks.

A second editor receives a separate coordinator and document. If a future product needs shared documents, define that ownership and synchronization explicitly. Putting a coordinator in a global module would change which editors and users share its state.

New requirementWhere it belongs
Add a selected-block status paneAdd a narrow output port and include it in the workspace’s publication rule. Existing panes need no new sibling references.
Change title validationChange the document’s edit rule and its result. The mediator forwards the outcome and refreshes panes only after an accepted change.
Load inspector data asynchronouslyCapture the relevant identity and generation; reject obsolete completions and respect the workspace lifetime.
Add billing, asset uploads, and background exportsGive those workflows their own owners. A workspace-selection coordinator should not absorb unrelated application policy.
Build UIs?The browser already coordinates radio buttons. A Share dialog’s one-owner rule makes that decision yours.

Where it already is in your components

If you have given every group of radio buttons in a form its own name, you already follow a rule that comes from this pattern. No radio button refers to another; the browser keeps them in agreement. The HTML standard groups radios that share a form owner (or have none), a tree, and a nonempty name, and when one becomes checked, “the checkedness state of all the other elements in the same radio button group must be set to false.” Only the radio you clicked fires input and change. The one it unchecked hears nothing.

That is why a reusable row cannot hard-code its name. A feedback form renders RatingRow once per statement inside one <form>. With name="rating", every row joined one group in our Chromium 153 run. In Svelte 5.57, answering the second row unchecked the first row’s Agree while that row’s state still said Agree. React 19.1.0 went the other way: after a change, restoreControlledInputState sets the other radios with that name in the same form back to their props, so the first row stayed checked, and the row you clicked showed nothing checked while its state said Neutral. Either way the form submitted one rating entry instead of two. A name built from the row’s data gives each row its own group again, and useId or $props.id() does the same for copies with nothing in their data to tell them apart.

RatingRow.tsx
import { useState } from 'react';

const options = ['Agree', 'Neutral', 'Disagree'];

// One row of a feedback form. The form renders a row per statement inside one <form>.
export function RatingRow({ id, statement }: { id: string; statement: string }) {
	const [rating, setRating] = useState<string | null>(null);
	// The browser groups radios that share a form and a name, and checking one unchecks
	// the rest. A literal name="rating" would make every row in the form one group.
	const name = `rating-${id}`;
	return (
		<fieldset>
			<legend>{statement}</legend>
			{options.map((option) => (
				<label key={option}>
					<input
						type="radio"
						name={name}
						value={option}
						checked={rating === option}
						onChange={() => setRating(option)}
					/>
					{option}
				</label>
			))}
		</fieldset>
	);
}

Coordinating your own components works the same way. React’s docs call it lifting state up: when two components should change together, “remove state from both of them, move it to their closest common parent, and then pass it down to them via props.” Svelte 5 components report up through callback props, which its migration guide recommends in place of createEventDispatcher. The textbook Share dialog below does that: the person rows and the link controls never read each other, and the parent’s handlers are the rules. One rule is missing on purpose.

When you have to own it

The Share dialog’s rules grow. A document has exactly one owner, so making Ben the owner has to make Ana an editor, and Ana’s own row cannot step down and leave nobody in charge. The link’s role can change only while “Anyone with the link” is on. Written into each row’s handler, those rules would make every control know about the others. Move them into share-settings.ts, which every change goes through. It knows all the participants and sends each one only its part: a row its role and whether it is locked, the link’s select whether it can change. share-settings.spec.ts tests the rules without mounting the dialog, and both panes below call the same functions.

share-settings.ts
// The rules for a document's Share dialog. The person rows and the link controls never
// read each other: every change goes through these functions, which return a new Share
// and leave the one they were given alone.
export type Role = 'viewer' | 'editor' | 'owner';
export type LinkRole = 'viewer' | 'editor';
export type Person = { id: string; name: string; role: Role };
export type Share = { people: Person[]; linkOn: boolean; linkRole: LinkRole };

export const roles: readonly Role[] = ['viewer', 'editor', 'owner'];
export const linkRoles: readonly LinkRole[] = ['viewer', 'editor'];

export const initialShare: Share = {
	people: [
		{ id: 'ana', name: 'Ana', role: 'owner' },
		{ id: 'ben', name: 'Ben', role: 'editor' },
		{ id: 'chen', name: 'Chen', role: 'viewer' }
	],
	linkOn: false,
	linkRole: 'viewer'
};

function copy(share: Share): Share {
	return { ...share, people: share.people.map((person) => ({ ...person })) };
}

// Exactly one owner. Choosing a new owner makes the previous owner an editor, and the
// owner's own row cannot step down, because that would leave the document without one.
export function changeRole(share: Share, id: string, role: Role): Share {
	const next = copy(share);
	const person = next.people.find((candidate) => candidate.id === id);
	if (!person) throw new Error(`No person with id ${id}`);
	if (person.role === 'owner') return next;
	if (role === 'owner') {
		for (const other of next.people) if (other.role === 'owner') other.role = 'editor';
	}
	person.role = role;
	return next;
}

export function setLink(share: Share, on: boolean): Share {
	return { ...copy(share), linkOn: on };
}

// The link's role can change only while the link is on.
export function setLinkRole(share: Share, role: LinkRole): Share {
	return { ...copy(share), linkRole: share.linkOn ? role : share.linkRole };
}

// What each control is sent: a row gets its role and whether it is locked, and the
// link's role select gets whether it can change.
export function controls(share: Share) {
	return {
		rows: share.people.map((person) => ({ ...person, locked: person.role === 'owner' })),
		linkRoleDisabled: !share.linkOn
	};
}

// A <select> reports a string. Turn it back into a role, or fail loudly.
export function parseRole(value: string): Role {
	const role = roles.find((candidate) => candidate === value);
	if (!role) throw new Error(`Unknown role ${value}`);
	return role;
}
export function parseLinkRole(value: string): LinkRole {
	const role = linkRoles.find((candidate) => candidate === value);
	if (!role) throw new Error(`Unknown link role ${value}`);
	return role;
}

When Ben becomes owner, Ana’s select receives editor as a value. In our React 19.1.0 and Svelte 5.57 runs only Ben’s select fired change, so the rule did not run again for Ana. That is section 04’s silent update, and here the browser supplies it: setting a control’s value from code is not a user action.

The parent owns the people and the link settings, passes values down, and takes callbacks up. Its role handler changes only the row that asked, so making Ben the owner leaves Ana as owner too.

ReactAlready in your code
ShareDialog.tsx
import { useState } from 'react';
import {
	initialShare,
	linkRoles,
	parseLinkRole,
	parseRole,
	roles,
	type LinkRole,
	type Person,
	type Role
} from './share-settings';

export function ShareDialog() {
	const [people, setPeople] = useState(initialShare.people);
	const [linkOn, setLinkOn] = useState(initialShare.linkOn);
	const [linkRole, setLinkRole] = useState(initialShare.linkRole);

	// This is coordination already. The rows and the link controls never talk to each
	// other: each reports up through a callback, and the rules live in this parent.
	// One rule is missing: a document has one owner, but making Ben the owner leaves
	// Ana as owner too, because this handler only changes the row that asked.
	const changeRole = (id: string, role: Role) =>
		setPeople((all) => all.map((person) => (person.id === id ? { ...person, role } : person)));

	return (
		<form>
			{people.map((person) => (
				<PersonRow
					key={person.id}
					person={person}
					onRoleChange={(role) => changeRole(person.id, role)}
				/>
			))}
			<LinkAccess on={linkOn} role={linkRole} onToggle={setLinkOn} onRoleChange={setLinkRole} />
		</form>
	);
}

function PersonRow({
	person,
	onRoleChange
}: {
	person: Person;
	onRoleChange: (role: Role) => void;
}) {
	return (
		<label>
			{person.name}
			<select value={person.role} onChange={(event) => onRoleChange(parseRole(event.target.value))}>
				{roles.map((role) => (
					<option key={role}>{role}</option>
				))}
			</select>
		</label>
	);
}

type LinkProps = {
	on: boolean;
	role: LinkRole;
	onToggle: (on: boolean) => void;
	onRoleChange: (role: LinkRole) => void;
};
function LinkAccess({ on, role, onToggle, onRoleChange }: LinkProps) {
	return (
		<fieldset>
			<label>
				<input type="checkbox" checked={on} onChange={(event) => onToggle(event.target.checked)} />
				Anyone with the link
			</label>
			<select
				aria-label="Link role"
				value={role}
				disabled={!on}
				onChange={(event) => onRoleChange(parseLinkRole(event.target.value))}
			>
				{linkRoles.map((option) => (
					<option key={option}>{option}</option>
				))}
			</select>
		</fieldset>
	);
}

07 / Recognize coordination before reaching for a library

The useful part is the rule about participants.

React’s sharing-state example coordinates two panels through their common parent: opening one changes which panel is active, while the children receive values and action handlers. What makes that parent a coordinator is its rule about which panel is active, not the lifted state by itself.

Qt’s model/view documentation shows views sharing a selection model so they remain aligned on selection. Its QItemSelectionModel API exposes selection state and change notifications. The shared selection is the connection; the document-edit rules here come from this lesson’s workspace.

A library can provide dispatch, notification, or middleware machinery. It cannot infer which workspace participants should change together, what a valid edit means, or which coordinator owns the lifetime. Those decisions are the substance of this lesson.

08 / Take the idea with you

What collaboration rule deserves its own owner?

Explain the design without saying “Mediator”: when this participant requests a change, this owner checks these conditions and updates these collaborators. Then name one rule that belongs to the document, and one responsibility that should stay outside the coordinator.

Use this pattern when participants repeat a coherent interaction rule and should not each know the whole collaboration. For two simple displays, a shared value and direct callbacks may be sufficient. Split a coordinator when its responsibilities no longer describe one workflow.

Connections to follow nextRelated lessons

Observer lets a subject notify registered reactions. Publish / subscribe routes messages by topic through a channel or broker: a publisher names the topic, not the subscribers, and each matching subscriber receives a copy of the same message. A mediator knows its participants and decides what each one gets; this coordinator sends Preview and Inspector a block and sends Toolbar only whether Rename is allowed. A mediator can use either mechanism for delivery, but its defining job is the rule that coordinates participants.

Facade gives a caller a focused entry point to a subsystem. Mediator concentrates relationships among collaborators. Their implementations can overlap; explain the responsibility instead of classifying a class only by the number of methods it calls.

Command represents an action, while a coordinator may accept and route those actions. A Chain of responsibility passes a request among possible handlers. Mediation does not imply that only one handler runs or that ordering is irrelevant.