← Design patterns
Behavior Eligibility, order, and stopping

Chain of responsibility

Handle it, or give the next handler a chance.

A project settings modal is open. Inside it, a dropdown is open too. On the page underneath, a document is still selected. You press Escape.

The dropdown should close first. Another press can close the modal. A later press can clear the page selection. Someone needs to decide who gets the request—and when to stop offering it.

TypeScriptGoPython One handoff contract · across the comparison

01 / Give each candidate a chance

The key does not need to know every control.

A small if / else if sequence is a reasonable first design: close a dropdown if one is open, otherwise close a modal, otherwise clear the selection. For a fixed set of controls, that may be all you need.

The pressure appears when independently built controls can participate. A new popover needs a place in the priority order. A modal sometimes refuses to close. A control unmounts and must stop receiving requests. The keyboard entry point is starting to know everybody’s behavior.

Chain of responsibility offers a request to an ordered set of handlers. Each handler can deal with it or pass it to the next candidate. In this lesson, a handled or blocked result stops the chain. If everyone passes, the caller receives an explicit unhandled result.

If you write components, you already lean on one. A keydown is offered to the nearest onKeyDown first and then to each one further out, until a handler calls stopPropagation().

The entry point knows the handler contract and the configured order. Each handler knows its own eligibility and action. An ordered array of functions held by the owner is enough to express that relationship. The textbook form instead has each handler hold its successor and forward the request itself, which moves ownership of the order out of the entry point and into the handlers; we keep the array so the owner that knows the layers also decides their priority.

02 / Make the handoff explicit

“I did nothing” can mean two different things.

A closed dropdown has no work to do, so it passes. A modal with an unsaved draft deliberately refuses this dismissal. Under our chosen policy, that refusal claims Escape: the modal stays open, and the page selection does not receive the request.

We call that terminal refusal blocked. It makes the reason visible without treating an intentional refusal as permission to try an unrelated action.

PASS → NEXT HANDLER

Not mine

Keep the state and offer the same request to the next candidate.

HANDLED → CALLER

Action accepted

Return the next state. Do not call the remaining handlers.

BLOCKED → CALLER

Stop here

Keep the state and explain the refusal. Do not fall through.

Default order: dropdown → modal → selection, with a clean draft
PressDecisions reachedResult
First EscapeDropdown handlesDropdown closes; modal and selection remain
Second EscapeDropdown passes; modal handlesModal closes; selection remains
Third EscapeDropdown passes; modal passes; selection handlesSelection clears
Fourth EscapeAll three passUnhandled; state stays as it is

One accepted request can still change several related fields. Closing the modal also closes its owned dropdown; move the modal handler first in the lab to watch it happen. That is one handler performing its cleanup, unlike three independent handlers acting on the same Escape.

An unhandled request is a normal outcome. It means the eligible chain ran out of candidates. The caller can leave the event alone or choose a documented fallback; success does not need to be invented at the end of the list.

03 / Read the stopping point

The return inside the loop is the important part.

Start with the dispatcher. It records each decision, continues only on pass, and returns as soon as a handler handles or blocks. The practical view adds the dropdown, modal, and selection rules.

The handlers receive the current state for each request and return decisions and new values. The caller owns that state and adopts the result, which keeps the same behavior runnable across the comparison.

A small key boundary ignores non-Escape keys, repeated keydown, and composition before entering the chain, so a held key cannot dismiss several layers. The lab adds a scoped browser keyboard listener around those decisions.

A handler explicitly passes, handles with a next state, or blocks while preserving state. The dispatcher offers the request in order and returns immediately on a terminal decision. Exhaustion is an explicit unhandled result.

TypeScriptReading
escape.ts
export type Decision =
	| { kind: 'pass'; reason: string }
	| { kind: 'handled'; next: UIState; reason: string }
	| { kind: 'blocked'; reason: string };
export type Handler = Readonly<{ id: string; handle: (state: UIState) => Decision }>;
export function runChain(state: UIState, handlers: readonly Handler[]): Outcome {
	const trace: Visit[] = [];
	for (const handler of handlers) {
		const decision = handler.handle({ ...state });
		trace.push({ handler: handler.id, decision: decision.kind, reason: decision.reason });
		if (decision.kind === 'pass') continue;
		return {
			status: decision.kind,
			handler: handler.id,
			reason: decision.reason,
			state: { ...(decision.kind === 'handled' ? decision.next : state) },
			trace
		};
	}
	return {
		status: 'unhandled',
		handler: null,
		reason: 'no handler accepted Escape',
		state: { ...state },
		trace
	};
}
GoAlongside
escape.go
type DecisionKind string

const (
	Pass    DecisionKind = "pass"
	Handled DecisionKind = "handled"
	Blocked DecisionKind = "blocked"
)

type Decision struct {
	Kind   DecisionKind
	Next   UIState
	Reason string
}
type Handler struct {
	ID     string
	Handle func(UIState) Decision
}

func RunChain(state UIState, handlers []Handler) Outcome {
	trace := []Visit{}
	for _, handler := range handlers {
		decision := handler.Handle(state)
		trace = append(trace, Visit{Handler: handler.ID, Decision: string(decision.Kind), Reason: decision.Reason})
		if decision.Kind == Pass {
			continue
		}
		next := state
		if decision.Kind == Handled {
			next = decision.Next
		}
		return Outcome{Status: string(decision.Kind), Handler: handler.ID, Reason: decision.Reason, State: next, Trace: trace}
	}
	return Outcome{Status: "unhandled", Reason: "no handler accepted Escape", State: state, Trace: trace}
}
Reading the TypeScript

The Decision union uses kind to distinguish three shapes. Only handled carries a next state. A blocked result cannot accidentally look like a pass merely because it has no replacement state.

A Handler is an object with an ID and a function property. No base class is needed. The dispatcher gives each handler a fresh copy of the four boolean fields, and copies the state it returns to the caller. readonly documents the intended API; these explicit copies keep observations independent.

The array order is execution order. Changing that array changes who gets the first chance.

Reading the Python

Python models the three decisions as frozen dataclasses, so Pass, Handled, and Blocked carry only the fields their result needs. The dispatcher passes a replaced UIState value to each handler and returns an immutable trace tuple.

The handler list is a tuple, but the contract is structural rather than inheritance-based: any callable with the right state/result shape can be placed in it. Python's runtime does not enforce the type aliases, so the checker exercises the shared fixture and stop boundary.

Reading the Go

Handler contains a function field, func(UIState) Decision. A slice orders those functions. UIState has only booleans, so passing and returning it copies the values without sharing a mutable nested object.

The decision constants name pass, handled, and blocked. The Next field is used only for handled; blocked preserves the current state. Go’s struct can represent more combinations than the intended contract, so the supplied handlers consistently use those named cases.

An empty handler string represents no claimant in the native result. The printable form shows a dash for the two visible representations of an absent value.

04 / Predict, change, observe

Watch the candidates that never get called.

Predict that the dropdown handles the first request, then send Escape. Its result should leave later handlers marked “Not reached.” Send another request using the new state and follow the handoff to the modal.

Restore the layers, then choose the broken stopping rule. The dropdown closes, but the dispatcher keeps offering the request. The modal and selection can act during the same keypress. The trace distinguishes a valid pass from a handled result that was incorrectly ignored.

ESCAPE / WHO GETS THE NEXT CHANCE?

One keypress. One handler gets to decide.

Start with all three layers active. Send Escape repeatedly, then change the order or stopping rule.

Project workspace

The dropdown belongs to the modal. The page selection sits underneath.

PAGERoadmap documentSelected
The panels draw application state rather than a live modal or menu.

Earlier handlers get the first chance. Disabled handlers are absent from the chain.

  1. 1Awaiting a request

    Only receives the request if earlier handlers pass.

  2. 2Awaiting a request

    Only receives the request if earlier handlers pass.

  3. 3Awaiting a request

    Only receives the request if earlier handlers pass.

dropdown → modal → selection

Escape is observed only while this input has focus. Other keys keep their normal behavior. Simulated request settings do not change your real key event.

Handlers visited0
Handlers that acted0
OutcomeReady
Read the decision trace

No handlers visited yet. Ignored input and an empty chain also produce an empty trace.

Only actual handler calls appear here.

For the refusal case, close the dropdown and mark the modal draft unsaved. Escape should stop at the modal without clearing the page selection. With the broken rule and an open dropdown, that dropdown may already have closed before the modal blocks.

Now move selection to the front, or disable the modal handler. The dispatcher follows the configuration you supplied, even if it produces poor UI behavior. Correct dispatch cannot repair an owner that supplies the wrong candidates or priority.

The simulation and real keyboard input run the same TypeScript rule set; the “continue after handled” dispatcher is a separate teaching variant. Native implementations are verified as native programs.

05 / Review the handoff

A stop is part of the result.

Reason about what happens after a handler acts, what a blocked close means, and who decides the order of candidates.

The dropdown has closed in response to Escape. What should this first-handler chain do next?

06 / Give priority an owner

The workspace builds the eligible path.

Picture a workspace controller receiving a key event from the focused part of the app. It identifies the active controls that may respond, orders them from the relevant inner layer outward, and sends one request through that path. The result tells it whether to adopt new state, present a refusal, or leave the input unclaimed.

The controller owns registration and lifetime. A menu registers while it participates and unregisters when it closes or unmounts. A reopened control should not leave a second stale handler behind. In our small model all three rules remain registered and use the latest flags to decide whether to pass.

Now add a command palette. Its own rule decides whether it can close; the owner decides where it belongs relative to the currently focused dropdown or modal. The existing dropdown and selection rules do not need to learn the palette’s internal behavior.

A production controller should derive the active path from real ownership and focus context. An unrelated page selection should not become eligible merely because a modal’s handler was accidentally omitted.

When dismissal becomes real application work

Keep eligibility fresh. Pass current state or a stable controller reference into a rule. A closure that captured “open” when the handler was registered can disagree with the current UI.

Define failure separately from decline. A failed save or rejected dismissal is not automatically an invitation for a lower layer to act. Decide whether the result is terminal, requires confirmation, or should be retried. Our blocked decision preserves state and stops; it does not implement a confirmation dialog or persistence.

Coordinate asynchronous requests. These examples are synchronous. If a handler awaits a confirmation or network result, another Escape can arrive while the first is pending. Choose how to serialize, consume, or cancel those requests, and ensure a late result still belongs to the same live control.

Restore focus and clean up owned children. A real modal close changes more than a boolean. The owner coordinates focus restoration, child popovers, and subscriptions. The lab’s schematic leaves that work out and keeps focus in its keyboard test input.

Keep traversal predictable. Take a stable view of the eligible handlers for a request if registration can change during dispatch. The supplied rules are finite, stateless, and local. A linked implementation also needs to avoid cycles.

07 / Recognize the handoff

“Next” is often a policy decision.

A chain is useful wherever several candidates can inspect a request and decide whether responsibility moves onward. The stopping rule matters as much as the order.

Express makes the handoff explicit.

Express middleware receives a next function. Its guide describes middleware that changes request or response data, ends a response, or passes control onward. Middleware can intentionally perform work and continue.

Read the Express middleware guide ↗

SvelteKit’s sequence chains handle hooks.

Each handle receives resolve. Inside sequence, calling it passes the request to the next handle, and the last one renders the route. A handle that returns its own Response without calling resolve ends the chain there: later handles and the route’s load never run, while an earlier handle still receives that response when its own resolve returns. The docs describe the same choice for one hook: change the response, or “bypass SvelteKit entirely”.

Read the handle hook docs ↗

Keyboard conventions explain the user’s expectation.

The WAI-ARIA Authoring Practices describe Escape closing the menu that contains focus and returning focus to its invoking context. Their modal-dialog pattern also includes Escape dismissal and focus behavior. Those conventions explain why one keypress should be resolved in its active context.

Read the menu keyboard pattern ↗ Read the modal-dialog pattern ↗
Build UIs?Every Escape that bubbles from a dropdown to a modal already walks a chain, and one day a command palette will need to go first.

Where it already is in your components

You have already written this chain. Put an onKeyDown on the dropdown, another on the modal, and another on the page (in Svelte, onkeydown), and one Escape reaches them nearest first. Nobody wrote the loop from section 03, and nearest-first is its fixed policy. Without a stop, all three act on the same press: the dropdown closes, the modal closes, and the selection clears. e.stopPropagation() is section 03’s return spelled the DOM way; call it in the dropdown and nothing further out hears that press.

The frameworks do not put those handlers on the elements, though. React 17 and later attach them “to the root DOM container into which your React tree is rendered”, and Svelte keeps “a single event listener at the application root” for keydown, click, and the other delegated events. When the real event reaches that root, the framework calls each handler on its path in order. A listener you add yourself with addEventListener on an element inside the app is passed on the way up, so it runs before every framework handler, however deep they sit. The textbook sample below adds the page’s listener that way: the dropdown closes and stops propagation, the modal stays open, and the selection clears anyway. Read React 17’s event delegation change.

Keep the page in the same system and the order comes back. In React that means onKeyDown on <main>. Svelte’s docs recommend on from svelte/events over addEventListener, “as it will ensure that order is preserved and stopPropagation is handled correctly”. With either change, the first press closes only the dropdown and the second only the modal. Read Svelte’s event delegation notes.

Our Outcome is application data. Browser event propagation has its own rules. stopPropagation() stops further travel through the event path; it does not cancel the browser’s default action or silence other listeners on the same element. MDN distinguishes those cases and points to stopImmediatePropagation() for the latter. Read the propagation contract.

preventDefault() addresses a cancelable browser default, such as navigation or scrolling. It does not stop propagation: listeners further out still run, and each can read event.defaultPrevented. A handler that checks that flag before acting treats the earlier call as a claim, but only because it chose to look. Read the default-action contract.

When you have to own it

The tree stops being the policy the day a modal with an unsaved draft has to refuse Escape and also keep the page selection from clearing, or a command palette arrives that must go first no matter which element has focus. Section 06’s owner takes over: one keydown listener on the window, the lesson’s ordered handler array, one request per press. It listens in the capture phase, which runs before any element’s listener and before the framework’s root. In React that owner is a useEscape hook; in Svelte it is a small module called from $effect. Both hand the outcome back as data and then make a separate decision about the DOM event: call preventDefault() and stopPropagation() only for a handled or blocked outcome. A claimed Escape then reaches no onKeyDown at all, while ignored and unhandled input carries on as usual. The lab’s Escape listener makes the same decision on its test input.

You can recognize the same decision while handling a shortcut inside a rich editor: should the active completion menu get first refusal, or should the containing editor react? The explicit chain makes that policy testable separately from DOM mechanics.

The dropdown and the modal stop Escape in their key handlers, but the page adds its listener by hand, so it runs before either of them and clears the selection on the first press.

ReactAlready in your code
Workspace.tsx
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';

const onEscape = (close: () => void) => (e: KeyboardEvent) => {
	if (e.key !== 'Escape') return;
	close();
	// Section 03's return, spelled the DOM way: nothing further out reacts.
	e.stopPropagation();
};

export function Workspace() {
	const [dropdown, setDropdown] = useState(true);
	const [modal, setModal] = useState(true);
	const [selection, setSelection] = useState(true);
	const page = useRef<HTMLElement>(null);

	// The page clears its selection with a listener added by hand, the way a
	// selection helper shared with non-React code would. This is the bug: React
	// runs every onKeyDown from one listener on its root, and the real keydown
	// passes <main> before it gets there. So this runs first, and the dropdown's
	// stopPropagation() comes too late to keep the selection.
	useEffect(() => {
		const node = page.current;
		if (!node) return;
		const clear = (e: globalThis.KeyboardEvent) => {
			if (e.key === 'Escape') setSelection(false);
		};
		node.addEventListener('keydown', clear);
		return () => node.removeEventListener('keydown', clear);
	}, []);

	return (
		<main ref={page}>
			{modal && (
				<div role="dialog" tabIndex={-1} onKeyDown={onEscape(() => setModal(false))}>
					{dropdown && (
						<div role="menu" tabIndex={-1} onKeyDown={onEscape(() => setDropdown(false))}>
							<button role="menuitem">Rename</button>
						</div>
					)}
				</div>
			)}
			<p>{selection ? 'Paragraph selected' : 'Nothing selected'}</p>
		</main>
	);
}

08 / Make the call

Choose what a handoff means before building the chain.

Consider this form when several independently defined candidates might handle a request, priority matters, and the caller should not embed each candidate’s internal behavior. Keyboard routing, fallback resolvers, and support routing can have that shape.

Keep a direct call when the recipient is already known. Keep a small if/else sequence when its fixed cases are easy to follow. A chain adds registration, ordering, and an exhaustion path; those are useful responsibilities only when your application needs them.

Similar collaborators, different contracts
NeedUseful connection
Give ordered candidates a chance until one claims a requestA chain with an explicit terminal result.
Let every subscriber observe an eventObserver or publish/subscribe; broadcast is intentional.
Choose one policy before the operation beginsStrategy.
Coordinate several collaborators in one workflowMediator.
Run required transformations in sequenceA pipeline; define whether each stage must run.

A middleware stack can combine several of these ideas. Logging may continue, authorization may stop, and a response handler may finish. Do not carry our “one handler acts” rule into every chain-shaped API without checking its actual contract.

09 / Take the idea with you

Explain why the second Escape has somewhere to go.

Try it without the pattern name: each candidate either passes or claims the request. Once somebody claims it, the remaining candidates wait for another request. The owner supplies the order and adopts the result.

Then change one assumption. The modal starts waiting for confirmation, or the dropdown unmounts while registered. Who owns the pending request? Who removes the stale candidate? Those answers define the system around the small loop.

Connections to follow nextRelated lessons

Observer makes notification useful to multiple listeners. Strategy supplies one chosen policy. State machine can make a modal’s open, confirming, and closed states explicit. Here, the chain decides which participant gets to make the next decision.