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.
Not mine
Keep the state and offer the same request to the next candidate.
Action accepted
Return the next state. Do not call the remaining handlers.
Stop here
Keep the state and explain the refusal. Do not fall through.
| Press | Decisions reached | Result |
|---|---|---|
| First Escape | Dropdown handles | Dropdown closes; modal and selection remain |
| Second Escape | Dropdown passes; modal handles | Modal closes; selection remains |
| Third Escape | Dropdown passes; modal passes; selection handles | Selection clears |
| Fourth Escape | All three pass | Unhandled; 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.
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
};
} 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.
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.
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.
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”.
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.
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.
| Need | Useful connection |
|---|---|
| Give ordered candidates a chance until one claims a request | A chain with an explicit terminal result. |
| Let every subscriber observe an event | Observer or publish/subscribe; broadcast is intentional. |
| Choose one policy before the operation begins | Strategy. |
| Coordinate several collaborators in one workflow | Mediator. |
| Run required transformations in sequence | A 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.