ComponentsGENERALFRAMEWORK-SPECIFIC

Drawing Component Boundaries

Six forces decide where a component ends — responsibility, state ownership, composition, reuse, render cost and accessibility — and "this file is getting long" is not one of them.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Where should one component end and the next begin, and which force is actually making that decision?

The user intent

A person is trying to complete one task on one screen. They never see a component boundary; they see a form, a list, a dialog. Every boundary you draw exists for the framework, the team and the future — not for them.

The obvious build

Split when a file gets long. Any visually distinct block becomes its own component, every file stays under a couple of hundred lines, and the tree stays tidy. It is a rule you can apply without thinking, which is exactly why it is attractive on a deadline.

Why it breaks

File length correlates with nothing. A 400-line form component whose entire job is one form is easier to change than eleven 40-line files that only make sense read together, because the second version hides the ordering and the shared state in the import graph.

How it breaks in a real browser
  • File length correlates with nothing. A 400-line form component whose entire job is one form is easier to change than eleven 40-line files that only make sense read together, because the second version hides the ordering and the shared state in the import graph.
  • Splitting by visual block cuts across state. FilterBar and ResultsTable look like two components and are one: the filter owns state the table needs, so every split forces the state up into a parent that now knows about both, or into a context that now couples everything under it (Who Owns This State?).
  • A boundary drawn without a reuse case produces a component with exactly one call site and a prop for every difference it might ever need. Six months later it has eleven boolean props and three of them are mutually exclusive.
  • Every boundary that adds a wrapper element adds a real DOM node, and a wrapper in a grid or flex container is not neutral — it becomes the flex item, and the children you meant to lay out are now grandchildren (Flexbox: One Axis at a Time).
  • Boundaries drawn purely by markup shape routinely sever a label from its control, or a ul from its li, because the visual grouping and the semantic grouping are not the same grouping (Semantics Are Behaviour).
  • On a large list, a boundary in the wrong place decides how much re-renders when one row changes. Most of the time that is irrelevant; on the one screen where it is not, the boundary is the whole problem (What a Component Costs to Render).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A component is four things bundled together: a unit of responsibility (one job, describable in a sentence without "and"), a unit of state ownership (something in here is the source of truth for something), a unit of reuse (it can be called from more than one place, or is deliberately called from exactly one), and in most frameworks a unit of change propagation — the granularity at which the framework decides what to re-evaluate.
  • The browser does not know components exist. It sees DOM nodes, computed style and layout boxes. A component boundary is free to the browser unless it introduces an element, at which point it costs a node, a style resolution and a position in the box tree like any other element (What a Mutation Costs).
  • The framework, unlike the browser, absolutely knows. In a virtual-DOM framework the boundary is where reconciliation can stop early; in a signals framework it is much less load-bearing because the tracking is at the value, not the component (Reactivity Models).
  • A boundary is also a contract surface. The moment you extract, the props become an API somebody else depends on, and every default you pick becomes a behaviour someone will rely on without reading it (What a Component Owes Its Caller).
  • And it is a trust surface. A component that accepts a string and renders it as HTML has moved an injection sink behind an innocent-looking prop name (Cross-Site Scripting).

What this makes the browser do

And which of it is avoidable.

  • Zero, for the boundary itself. Components are a source-level construct that the framework erases before anything reaches the DOM.
  • One extra element, one extra style resolution and one extra box, for every wrapper div a boundary drags along. This is small per instance and stops being small inside a list rendered a thousand times.
  • Style recalculation over a larger subtree when a boundary forces a class to be applied higher than the change actually needs — invalidation is scoped by the DOM, not by your component tree (Style Invalidation).
  • Nothing extra at layout or paint, unless the wrapper participates in layout: an added element inside a flex or grid container changes the box tree, and that is a real geometry change, not a refactor.

Six forces, and file length is not one of them

When people disagree about a boundary, they are usually optimising different forces without saying which. Making the forces explicit turns an aesthetic argument into an engineering one: you can agree that reuse says extract and render cost says do not, and then decide which of the two you are actually paying for on this screen.

The forces are not equally weighted, and their weights change with the codebase. In a product surface with one call site, responsibility and state ownership dominate and reuse is nearly irrelevant. In a design system, reuse and accessibility dominate and render cost is somebody else's context. Applying a single rule everywhere is how a team ends up with a design system that is under-abstracted and a product screen that is shattered into confetti.

ForceThe question it asksSays "extract" whenSmell when it is ignored
ResponsibilityCan I name this job in one sentence with no "and"?A subtree has a job the parent does not need to know aboutA component whose name is a noun for a region, not a job
State ownershipWho is the source of truth here?A piece of state has exactly one owner and a bounded readershipState lifted three levels because two siblings were split apart
CompositionDoes the caller need to decide what goes inside?The variation is content, not configurationA growing list of boolean props that switch what renders
ReuseDoes a second real call site exist today?Two call sites disagree only about data, not about behaviourA component with eleven props and one caller
Render costWhat re-evaluates when this changes?Measurement shows a hot subtree re-running for unrelated stateA boundary added for performance with no profile behind it
AccessibilityWho owns the name, the role, the focus, the announcement?A whole interaction pattern can live behind one boundaryA label and its control on opposite sides of a boundary

The same screen, cut two ways

Take a screen with a filter bar and a results table. The visual reading gives two components, which feels obviously right and immediately forces the filter state into a parent that now imports both and knows the shape of each. Every new filter changes three files, and the parent slowly becomes the god screen it was supposed to prevent.

The state-ownership reading gives a different cut: one component owns the query, and the presentation of that query — the inputs, the chips, the empty state, the table — are leaves that receive values and emit events. The parent got smaller, not bigger, and the boundary now matches the thing that actually changes together.

Two cuts of one screen
Cut by visual block
<Page>            // owns filters, sort, page, selection, fetch
  <FilterBar ...9 props />
  <ResultsTable ...11 props />
</Page>
// every filter change edits Page, FilterBar and ResultsTable
Cut by state ownership
<ResultsBrowser>        // owns the query; the only stateful node
  <QueryControls />     // reads query, emits changes
  <ResultsList />       // reads results, emits selection
</ResultsBrowser>
// a new filter edits the query type and one control

The second cut puts the boundary where the change boundary already was. In the first, the parent has to know the internals of both children in order to keep them consistent, so the coupling that the split was meant to remove has simply moved into the prop lists.

A decision you can actually run in review

Most boundary arguments happen in a pull request, where the useful question is not "is this the perfect decomposition" but "does this specific extraction pay for itself". The options below are all defensible; the criteria are what make one of them right here.

Note that "leave it inline" is a first-class option and not a concession. Inline markup inside a component that owns the responsibility is the cheapest thing to read, the cheapest to delete, and the easiest to extract later once a second call site tells you what the interface should be.

Should this become its own component?

A block of markup inside a growing component. What do you do with it?

Leave it inline

when It has no state, one call site, and reads fine in place. This is the default and stays the default until something changes.

cost The file gets longer, and a reviewer who uses length as a proxy for complexity will push back. You need a reason ready.

Extract as a leaf

when It has a nameable job and takes data in, emits events out, and owns nothing. Test it in isolation, render it anywhere.

cost One more file, one more import, one more stack frame, and a props interface that is now a contract (What a Component Owes Its Caller).

Extract with the state it owns

when The block is the only reader and writer of a piece of state. Moving the state with it shrinks the parent.

cost The state becomes harder to observe from outside, and lifting it back out later is a real refactor if the parent ever needs it (Who Owns This State?).

Extract as a slot host

when Callers need to vary the contents rather than toggle between fixed variants. The component owns the frame, the caller owns the filling.

cost The contract is less prescriptive, so callers can compose something inaccessible or inconsistent unless the slot is constrained (Composition and Slots).

Promote to the design system

when Multiple teams need it, and its accessibility and visual behaviour should be decided once.

cost It is now versioned, documented and breaking-change-managed. The cost of getting the API wrong went up by an order of magnitude (Design Systems).

How to build it

Most important first.

  • Name the component before you extract it. If the honest name contains "and", or is Wrapper, Container or Section, you have found a grouping, not a responsibility. Groupings are what children is for (Composition and Slots).
  • Draw the boundary where the state boundary already is. Ask what this subtree owns; if the answer is "nothing, it just displays what it is given", it is a leaf and should stay one (Who Owns This State?).
  • Extract on the second real call site, not the first imagined one. A component with one call site is an indirection; a component with two is an abstraction, and the second call site tells you which differences are real props.
  • Keep the boundary at a semantic seam where you can. A boundary that owns a whole dialog, a whole listbox or a whole field-with-label-and-error can also own that pattern's keyboard behaviour and announcement; a boundary drawn mid-pattern cannot (Accessible Component Patterns).
  • Prefer composition over configuration. When a component starts growing props that only switch what it renders, that is the shape asking for slots (Composition and Slots).
  • Let render cost move a boundary only when you have measured it. It is a legitimate reason and it is the last one on this list on purpose (Measure Before Optimising).
  • Avoid over-componentization deliberately, as a design goal and not as an accident. Indirection with no behaviour is a cost with no benefit (Over-Componentization).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Boundaries decide who owns the accessible name. If a component renders a control but the label lives in the caller, the two must agree on an id or the control ships nameless — so the boundary must either own both or expose the id in its contract (What a Component Owes Its Caller).
  • Some ARIA relationships require a specific DOM ancestry: option inside listbox, tab inside tablist, li inside ul. A boundary that wraps children in a div can silently break the parent-child relationship the role depends on (The Rules of ARIA).
  • Focus management belongs to whichever boundary owns the interaction, not to whichever boundary happens to render the element. A dialog owns focus trapping and restoration; the button inside it does not (Focus Management).
  • Announcement is a property of the pattern, not of the element. If a boundary cuts a status message away from the action that caused it, the live region either fires with no context or does not exist (Live Regions and Announcement).
  • A useful test for any boundary: can this component, alone, be operated by keyboard from a cold start? If yes it is a real unit. If it needs the parent to hold the keyboard model, the boundary is in the wrong place (Keyboard Operability).

What can go wrong

Failure modes
  • The god screen: one component that owns fetching, filtering, sorting, selection, editing and submission. Every change touches it, every test mounts everything, and nobody can tell which state the bug is in.
  • The prop-only component: extracted for tidiness, reads none of its props, forwards all of them. Now every new prop must be threaded through it, and its stack frame is pure noise (Prop Drilling, Context and Global State).
  • The boundary that split a pattern: a Label component and an Input component that no longer share an id, so the label is decorative text and the control has no accessible name.
  • The boundary that hid a fetch: a leaf component that quietly requests data on mount, so a list of thirty of them makes thirty requests and nobody can see why from the parent (Five Components, One Request).
  • The mitigation failing: you split a heavy screen to reduce re-render cost, but state stayed in the parent, so the parent still re-renders and every child re-renders with it. The boundary moved; the ownership did not.
What can arrive out of order
  • Two boundaries fetching independently on mount can resolve in either order, so a parent that derives layout from both must handle either arriving first (The Life of a Fetch).
  • A boundary that unmounts while an async handler is in flight leaves a callback that will run against a component that no longer exists — the framework may warn, may silently drop it, or may leak, depending on which one you use.
Security
  • A component boundary is a trust boundary the moment a prop reaches a dangerous sink. dangerouslySetInnerHTML, v-html, @html and [innerHTML] all turn a string prop into script execution, and the prop name rarely says so (Cross-Site Scripting).
  • Frameworks escape text interpolation by default. That default is a property of the framework's rendering path, not of your component — a component that bypasses it hands every current and future caller an injection point (Sanitization and Trusted HTML).
  • Components that render a caller-supplied href or src inherit the javascript: and data: URL problem. A boundary that accepts a URL should say in its contract what it validates.
  • Splitting a screen does not create an authorization boundary. A component that only renders for admins is a rendering decision; the server is still the only place the rule exists (Authorization-Aware UI).
Misreads
  • "Small components are good components." Small is a consequence of a narrow responsibility, not a target. A component that is small because half its job leaked into its parent is worse than the long version.
  • "A component per design element." Design elements and behavioural units are different taxonomies. A card in Figma may be one component or three, and neither is a design mistake.
  • "If I extract it now, reuse gets easier later." Reuse gets easier when you know the second use case. Extracting first optimises for a shape you have not seen and usually guesses wrong.
  • "Deep trees are just a style preference." They are also a debugging cost, a stack-trace cost and, past a point, a runtime cost. Preference stops applying when there is a bill (Over-Componentization).
  • "The framework will make my boundaries cheap." Some do, some do not, and the same code has different boundary costs in React and in Solid. Boundaries are not portable performance decisions (Reactivity Models).

Measuring it, and what changes in the field

How you would see this
  • The component tree in your framework's devtools, read for depth rather than for correctness. A path from root to a leaf button that passes through eight components which read no props is a measurable smell.
  • A profiler recording of one interaction, showing which components re-evaluated. This tells you whether a boundary is doing the containment work you assumed it was (What a Component Costs to Render).
  • Bundle analysis grouped by directory: a component directory whose size is dominated by wrappers and re-exports is telling you where the indirection went (Bundle Analysis).
  • Element count for a single row of a list. Compare it to what the design actually needs; the difference is wrapper tax (What a Mutation Costs).
  • The blunt one: git log --format= --name-only over a few months. Files that always change together are one boundary that got split; a file that changes for six unrelated reasons is one boundary that should be several.
Slow device, slow network, large data, old tab
  • On a slow device, wrapper elements and deep trees stop being free: more nodes means more style resolution and more framework work per update, and both scale with CPU (The Real Cost of JavaScript).
  • On a large dataset, the boundary inside the row is multiplied by the row count. Three wrapper elements per row across five thousand rows is fifteen thousand nodes that exist for the source code's benefit (List Virtualization).
  • On a small team with one call site, the cost of a wrong boundary is a refactor. On a design system consumed by other teams, it is a breaking change with a deprecation window (Design Systems).
  • In a long-lived tab, boundaries that own subscriptions decide what gets cleaned up on unmount. A boundary in the wrong place is how a listener outlives the screen that created it (Memory Leaks).
What this costs
  • Boundaries drawn by responsibility produce components of uneven size, and some of them will be long. That looks undisciplined in review and is usually correct; the alternative is even sizes with uneven cohesion.
  • Waiting for the second call site means you will occasionally write something twice. Duplication is cheap to fix once you can see both versions; the wrong abstraction extracted from one example is expensive to fix ever.
  • Extracting at semantic seams sometimes forces a component to be bigger than the visual design suggests — a whole combobox rather than an input and a list. That is the price of the pattern being correct end to end.
  • Optimising boundaries for render cost couples your architecture to a framework's current change-detection strategy, which is exactly the sort of thing that changes under you (Reactivity Models).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALResponsibility, state ownership, reuse and semantic seams are framework-independent forces — they are the same questions in React, Vue, Svelte, Angular, Solid and in a codebase with no framework at all, because they come from the DOM and the team rather than from a runtime.
  • FRAMEWORK-SPECIFICHow much a boundary costs at runtime is not portable: React and Vue re-evaluate at component granularity so a boundary is where subtree work can stop, while Solid and Svelte track dependencies at the value level, so moving a boundary changes almost nothing about what re-runs. The same refactor is a performance fix in one and a no-op in the other.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designapi-granularity
Domains that do not exist yet
  • Software Design — cohesion and coupling as the general form of this question, plus dependency direction: a boundary that points the wrong way is the reason a leaf component imports a store.
  • Testing & Reliability Engineering — a boundary is also a test seam, and "what can I mount alone" is often the sharpest available evidence that the decomposition is right.