01 / Let a group be a usable thing
The move tool can keep one kind of request.
For a flat canvas, a list of elements and a loop are sensible. The tool applies a delta to each selected element. Then users want a heading and badge to stay together, and that small group needs to live inside a larger card.
You could teach every tool to distinguish an element from a group, inspect its children, and keep walking. Soon moving, rendering, and other operations each carry their own version of the nesting rule.
Composite represents individual parts and groups through a common interface, so a caller can apply the same operation to either. An element handles its own work. A group asks its children to perform that work, and a child can itself be another group.
Here, that interface is Graphic. Both Element and Group can move and render. The move tool accepts a Graphic and issues one request.
It does not need a separate branch for a whole card.
02 / Follow the same operation downward
The card contains a group that contains two elements.
Our canvas has two immediate children: a card and a separate caption. The card contains a photo and a details group. Details contains the heading and badge. Selecting the card includes three leaf elements; the caption remains outside it.
One request
The move tool supplies a delta. It accepts either a leaf or a group.
Delegate in order
The card asks its photo and details group to move. Details asks its heading and badge.
Do the leaf work
Each element updates its position in a new value and renders one record.
Moving the card by (20, 10) reaches the photo once, the heading once, and the badge
once. Groups do not add a second visible object to the rendered artwork. Their render operation
combines the records returned by their children, preserving order.
| Element | Before | After | Reached through |
|---|---|---|---|
| Photo | 40, 60 | 60, 70 | card → photo |
| Heading | 230, 65 | 250, 75 | card → details → heading |
| Badge | 250, 140 | 270, 150 | card → details → badge |
| Caption | 40, 270 | 40, 270 | Outside the selected card |
The group owns the recursive step. If the tool also walks every descendant and moves it separately, the photo gets the delta twice. The heading and badge receive it three times: through the card, through details, and through their own extra calls.
An empty group is useful to think about too. It accepts a move and has no child work to perform. Rendering it returns an empty list. The caller can keep the same operation without inventing a special “empty graphic” exception.
03 / Read the contract
A leaf does the work. A group combines it.
The basic form shows a leaf implementing Graphic. The practical form adds Group with children typed as that same interface. Follow the method call inside its loop: each child decides how to carry out the operation.
These examples return a new moved subtree. The document owner must adopt it; ignoring the return value leaves the current artwork unchanged. Rendering produces independent drawing records. The browser turns those records into schematic SVG shapes, while the standalone programs print their coordinates.
All positions are in shared canvas space, and a group has no position or transform of its own.
Graphic is the common operation contract. An Element moves by returning a new element and renders one drawing record. The trace makes each method call visible. Supporting Mark fields are in the complete file.
export interface Graphic {
readonly id: string;
moveBy(dx: number, dy: number, trace: string[]): Graphic;
render(): Mark[];
}
export class Element implements Graphic {
readonly id: string;
readonly #mark: Mark;
constructor(mark: Mark) {
this.id = mark.id;
this.#mark = Object.freeze({ ...mark });
Object.freeze(this);
}
moveBy(dx: number, dy: number, trace: string[]): Element {
trace.push(this.id);
return new Element({ ...this.#mark, x: this.#mark.x + dx, y: this.#mark.y + dy });
}
render(): Mark[] {
return [{ ...this.#mark }];
}
} type Graphic interface {
ID() string
MoveBy(dx, dy int, trace *[]string) Graphic
Render() []Mark
}
type Element struct{ mark Mark }
func NewElement(mark Mark) Element { return Element{mark} }
func (e Element) ID() string { return e.mark.ID }
func (e Element) MoveBy(dx, dy int, trace *[]string) Graphic {
*trace = append(*trace, e.ID())
mark := e.mark
mark.X += dx
mark.Y += dy
return NewElement(mark)
}
func (e Element) Render() []Mark { return []Mark{e.mark} } Reading the TypeScript
Graphic names the operations both classes satisfy. A Group stores Graphic
children, so the call to child.moveBy works for either implementation. The move
tool does not inspect their concrete classes.
map builds the moved children in order; flatMap joins their drawing
records. The constructors copy and freeze the scalar record or child array. The provided node
classes expose no mutation methods, so unchanged children can be retained safely in another
version.
readonly alone is a type-system restriction. The copying and freezing are deliberate
choices in this implementation, and every future Graphic implementation must honor the same
contract. Sharing an arbitrary mutable child would change the ownership story.
Reading the Go
Go’s interfaces are satisfied by matching methods. Element and Group both satisfy Graphic, and []Graphic can hold them together. Value receivers keep
the methods on the provided types from replacing their receiver’s state.
A slice still refers to backing storage, so the Group constructor copies its input slice. Rendering creates new slices of value records, and moving constructs a new Group. The fields are unexported; extracting these types into a package would enforce that boundary against external callers.
The trace is passed as *[]string so each visited node can append to the same
observation. It records traversal; it does not decide what moves.
Reading the PythonProtocols, dataclasses, and tuples
The Graphic Protocol gives leaves and groups one move/render contract. Frozen
dataclasses hold marks and leaves; Group copies its iterable into a tuple before delegating
to each child in order.
A move returns a new subtree and appends every visited ID to the shared trace. Render returns fresh mark lists, so the caller can inspect output without changing the artwork.
04 / Predict, change, observe
One call should reach three elements.
The card starts selected. Predict three leaf move calls, then move it. Compare the artwork, the coordinates, and the recorded method calls. The caption should stay in place.
Reset and switch to the broken dispatch. The tool now calls the card and separately calls its descendants. Predict again: the photo receives two moves; the heading and badge receive three each. The grouping itself is unchanged—the caller repeated work already owned by the groups.
For a structural experiment, choose the flat layout and clear the selection. Select heading and badge, then group them. Select that new group and photo, and group again. You have built a nested card without changing a single coordinate.
Ungroup one of those groups. Its immediate children stay where they are and become selectable at the parent level. Now select a different part and predict how far the next operation reaches.
The lab calls the same TypeScript move/render methods shown above, with separate editor code for selection and grouping. The native implementations run the same move/render cases as standalone programs.
05 / Make a decision
Keep the operation and the structure distinct.
Review who should recurse, what an ungroup action should preserve, and which capabilities belong on the common interface.
06 / Give it a real owner
The editor owns membership. A tool asks for behavior.
Picture a user pressing an arrow key in a slide editor. The editor resolves the selection, removes descendants already covered by a selected ancestor, and sends one delta to each remaining root. It replaces those subtrees with their returned values, then asks the canvas to render.
The move tool should not rebuild the membership rule. The editor also owns grouping, ungrouping, IDs, and selection changes. It can inspect groups for that structural work while ordinary move/render callers keep the small Graphic contract.
Our grouping helper accepts adjacent siblings and replaces that contiguous run with one group. This preserves drawing order: elements that used to paint between two selected items cannot silently jump above or below them. The new group uses their existing canvas coordinates. Ungrouping replaces only that group with its immediate children.
Imagine adding a sticker element. It supplies the common move and render behavior; existing groups can contain it without changing their delegation. If stickers need a distinct record kind, the downstream drawing code still needs to understand it.
The boundaries a larger editor would need
Coordinate systems. This example moves leaf coordinates directly. A richer editor may retain transforms on groups and combine parent and child transforms when drawing. Reparenting then needs to preserve the intended world-space appearance. Applying both a parent transform and the same delta to each leaf would mix two models.
A tree of placements. These scenes have one parent per placement and unique IDs. The lab’s edits preserve that structure. The generic constructors do not validate arbitrary imported graphs. Reusing one placement under two parents can cause repeated work; a cycle prevents recursive traversal from finishing. Validate imported structure and place shared image assets behind separate placement nodes.
Immutable versions and cost. A move allocates a new visited subtree and leaves the previous one usable, an ownership policy this example chose. Rendering also creates intermediate lists at groups; very deep nesting can repeat copying. A shared output buffer, local transforms, or carefully managed mutable nodes may suit another workload. Measure the actual editor before choosing.
Limits and failure. The source expects trusted integer geometry with sums in its documented range. The lab validates deltas and rejects any candidate that would put a leaf beyond its coordinate limit, retaining the previous root. Imported designs need their own size, depth, and numeric checks.
Other operations need honest semantics. A group’s selection bounds can come from its children, but cached bounds need invalidation. Visibility, opacity, hit testing, clipping, and resource disposal each need a clear combination rule. Sharing one interface is useful only when each implementation can honor the operation’s promise.
Build UIs?Every appendChild you call goes through an interface a text node shares with an element, and a settings page with section checkboxes makes that choice yours.
Where it already is in your components
If you reach for firstElementChild instead of firstChild, you
already follow a rule that comes from this pattern. The DOM is a Composite the browser
runs: an element and the text inside it are both a Node, and the Node interface gives every node childNodes, textContent, and appendChild. An element answers textContent by combining its
children, the way a group combines drawing records: it returns “the concatenation of the data of all the Text node descendants” in tree order, and an empty element returns an empty string.
The DOM made the other choice from the one section 08 weighs: child operations sit on the
shared interface, so a leaf has to refuse them. The whitespace between a <ul> and its first <li> is a text node, and MDN puts it plainly: “Any whitespace will create a #text node.” So list.firstChild?.appendChild(item) type-checks, because TypeScript’s DOM
types give that child appendChild too, and then Chromium throws a HierarchyRequestError: “Failed to execute 'appendChild' on 'Node': This node
type does not support this method.” Asking for firstElementChild skips the text
leaves.
The same insertion checks keep the DOM a tree, which this lesson’s editor has to guarantee for itself. Append an element
that already has a parent, and the browser removes it from that parent first, so one element
never sits in two lists. Append an element into its own descendant, and Chromium throws “The
new child element contains the parent.” An SVG <g> is the geometric version of the idea: MDN notes that “Transformations applied to the <g> element are performed on its
child elements,” the group-transform model the boundaries above describe.
When you have to own it
Now your app has a notification settings page. Email holds a Billing section, with invoices and payment failures, and a Product section; push notifications sit outside it. Every section has its own checkbox: checked when everything under it is on, a dash when only some of it is, and a click turns everything under it on or off. MDN names this the most common use of a checkbox’s indeterminate state: “If any one or more of the sub-options have a different state than the others, the owning checkbox is in the indeterminate state.”
The decision is where a section’s state lives. Store a checked flag on each section, and every toggle has to walk up and correct its ancestors, or an effect has to keep them in step. Give toggles and sections one interface instead. Each reports how many of its settings are on, out of how many, and each accepts a request to set an id. A section adds up its children and passes the request down, so a toggle three levels deep needs no code that knows about sections.
export type Tally = { on: number; total: number };
// A single toggle and a whole section answer the same two requests.
export interface Setting {
readonly id: string;
tally(): Tally;
set(id: string, on: boolean): Setting;
}
export class Toggle implements Setting {
readonly id: string;
readonly on: boolean;
constructor(id: string, on: boolean) {
this.id = id;
this.on = on;
}
tally(): Tally {
return { on: this.on ? 1 : 0, total: 1 };
}
set(id: string, on: boolean): Toggle {
return id === this.id ? new Toggle(this.id, on) : this;
}
}
export class Section implements Setting {
readonly id: string;
readonly children: readonly Setting[];
constructor(id: string, children: readonly Setting[]) {
this.id = id;
this.children = Object.freeze([...children]);
}
tally(): Tally {
let on = 0;
let total = 0;
for (const child of this.children) {
const part = child.tally();
on += part.on;
total += part.total;
}
return { on, total };
}
set(id: string, on: boolean): Section {
// Setting this section sends each child the same request under its own id.
const all = id === this.id;
return new Section(
this.id,
this.children.map((child) => child.set(all ? child.id : id, on))
);
}
}
// Derived on every render, never stored, so a section cannot disagree with its children.
export function checkbox({ on, total }: Tally): { checked: boolean; indeterminate: boolean } {
return { checked: total > 0 && on === total, indeterminate: on > 0 && on < total };
}
The checkbox is derived from that tally on every render, so it cannot disagree with its
children. Decide what an empty section means, as this lesson did for an empty group: here
it counts nothing, so an empty Beta section never turns its parent into a dash. A
recursive component draws the rows, but it only reads tally() and sends set; the settings tree is the Composite.
Set indeterminate as a property: MDN notes “it cannot be set using an HTML
attribute.” In Svelte 5.57, indeterminate={state.indeterminate} on the input set the property. In React 19.1.0 the same prop left it unset and logged “Received true for a non-boolean attribute indeterminate,” so set it in a ref callback. In
Chromium, clicking a section that showed a dash checked it and turned on every setting
under it, and the accessibility tree reported the dash as “mixed.”
07 / Recognize the relationship
Grouping is already a public feature of drawing systems.
The useful connection is a compound item that remains usable as an item. Different libraries choose different geometry and ownership models around that relationship.
Qt has a group that is also a graphics item.
QGraphicsItemGroup inherits from QGraphicsItem and treats its children
together as one item. Qt documents that adding or removing items through its grouping methods
preserves their scene-relative position and transformation. That is the same user expectation
behind our “ungroup without a jump” exercise, implemented with a richer graphics model.
Three.js gives grouped objects a shared handle.
Three.js documents Group as a clearer way to work with groups of Object3D objects. Its example adds two meshes and manipulates them as a group.
Object3D exposes children and distinguishes local and world transforms.
08 / Make the call
Find an operation that makes sense for both.
Composite fits when parts and nested groups need a meaningful shared operation: render a graphic, total a bundle, or evaluate a group of conditions. The group’s combination rule must be clear. Drawing preserves child order; a total adds contributions; a condition group might combine results with AND or OR.
Keep a flat list when the problem is flat. If the variants are fixed and different operations need to inspect their structure, an explicit recursive data type with separate functions can be clearer. A tree data structure supplies nesting; Composite adds a useful uniform behavior for its callers.
| Need | Useful starting point |
|---|---|
| Operate on a part or a nested whole through one contract | Composite, with a defined delegation or combination rule. |
| Apply an operation to unrelated flat items | A list and a loop. |
| Add behavior around a compatible subject | Decorator, whose wrapper has a different responsibility. |
| Control access to a represented resource | Proxy. |
| Share image or style data among placements | Shared immutable assets or Flyweight, alongside the scene tree. |
Uniformity does not mean every node must support adding children. A photo can honestly move and render; it cannot honestly contain another photo in this model. The textbook form sometimes declares add and remove on the shared interface so a client never has to ask which kind of node it holds; the price is that leaves must reject those calls. We keep child operations on the group, trading that transparency for a contract every node can honor.
09 / Take the idea with you
Explain how one request reaches the whole card.
Try explaining it without the pattern name: the tool asks one graphic to move. An element changes its own position in a new value. A group asks each child to do the same, and the owner adopts the result.
Then change the design. Move the badge outside details but leave it inside the card. Which selection will still move the badge? Which will stop reaching it? You can answer by following membership, without changing the move tool.
Connections to follow nextRelated lessons
Decorator also uses compatible interfaces, with behavior wrapped around a subject. Flyweight can share the assets behind separate placements. Visitor keeps an operation outside the tree, so a new behavior can be added without editing every node type. Interpreter uses the same tree shape to give an expression its meaning, where each node evaluates rather than moves. Ownership, aliasing, and lifetimes helps explain who retains scene versions and what may safely be shared.