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.
Where should one component end and the next begin, and which force is actually making that decision?
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.
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.
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.
- 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.
FilterBarandResultsTablelook 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
ulfrom itsli, 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).
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
diva 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.
| Force | The question it asks | Says "extract" when | Smell when it is ignored |
|---|---|---|---|
| Responsibility | Can I name this job in one sentence with no "and"? | A subtree has a job the parent does not need to know about | A component whose name is a noun for a region, not a job |
| State ownership | Who is the source of truth here? | A piece of state has exactly one owner and a bounded readership | State lifted three levels because two siblings were split apart |
| Composition | Does the caller need to decide what goes inside? | The variation is content, not configuration | A growing list of boolean props that switch what renders |
| Reuse | Does a second real call site exist today? | Two call sites disagree only about data, not about behaviour | A component with eleven props and one caller |
| Render cost | What re-evaluates when this changes? | Measurement shows a hot subtree re-running for unrelated state | A boundary added for performance with no profile behind it |
| Accessibility | Who owns the name, the role, the focus, the announcement? | A whole interaction pattern can live behind one boundary | A 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.
<Page> // owns filters, sort, page, selection, fetch <FilterBar ...9 props /> <ResultsTable ...11 props /> </Page> // every filter change edits Page, FilterBar and ResultsTable
<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.
A block of markup inside a growing component. What do you do with it?
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.
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).
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?).
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).
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,ContainerorSection, you have found a grouping, not a responsibility. Groupings are whatchildrenis 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:
optioninsidelistbox,tabinsidetablist,liinsideul. A boundary that wraps children in adivcan 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
- 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
Labelcomponent and anInputcomponent 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.
- 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.
- A component boundary is a trust boundary the moment a prop reaches a dangerous sink.
dangerouslySetInnerHTML,v-html,@htmland[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
hreforsrcinherit thejavascript:anddata: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).
- "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
- 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-onlyover 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.
- 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).
- 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.
- — 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.