ComponentsGENERALFRAMEWORK-SPECIFIC

Over-Componentization

Indirection with no behaviour: a component per div, props that only pass through, and a stack ten frames deep to render one button.

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

When has splitting things up stopped helping, and what does the excess actually cost?

The user intent

Someone needs to change the label on a button. They should be able to find it, change it, and be confident about what else moved.

The obvious build

More components means more modularity. Small files are easier to read, each piece is independently testable, and a shallow file is always better than a deep one. This is good engineering advice applied consistently — which is the problem.

Why it breaks

The button has a stack trace ten frames deep: Page to Layout to Content to Section to SectionBody to Row to RowInner to ActionArea to ButtonWrapper to Button. Eight of those read no props and render one element.

How it breaks in a real browser
  • The button has a stack trace ten frames deep: Page to Layout to Content to Section to SectionBody to Row to RowInner to ActionArea to ButtonWrapper to Button. Eight of those read no props and render one element.
  • Changing the label means opening five files to find which one owns the string, and the answer is that it is passed from the top.
  • Adding one prop means adding it to every component on the path, each of which now has a slightly wider interface it does not use (Prop Drilling, Context and Global State).
  • Search stops working. Grepping for the visible text finds a constant; grepping for the component finds a re-export; grepping for the prop finds nine forwarding declarations.
  • Every wrapper that renders an element adds a real DOM node, and in a list that multiplies. A row that needs three elements and has nine is a real cost at five thousand rows (List Virtualization).
  • Framework devtools become unusable: the component tree is mostly scaffolding, and finding the node that owns the state means scrolling past dozens that own nothing.
  • Tests multiply without gaining coverage. Each wrapper gets a test that asserts it renders its children, which is a test of the framework.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • The unit of comprehension is not the file; it is the thing you have to hold in your head to make a change. Splitting a coherent unit across eight files does not reduce that; it adds navigation on top of it.
  • Each boundary has a fixed cost that is easy to under-count: a file, an import, an export, a props interface, a stack frame, a devtools node, a test file, and a place where a name can drift from what it does.
  • A component earns that cost by owning something — state, a decision, a behaviour, a pattern, a genuine reuse. A component that owns nothing is pure indirection, and indirection is only valuable when it hides something.
  • The specific tell is the pass-through prop: a prop a component declares, does not read, and forwards. A component whose props are all pass-through is, by definition, doing nothing that a fragment or a slot could not do (Composition and Slots).
  • The second tell is the component per element: CardTitle rendering exactly <h3 className="card-title">{children}</h3>. That is a styling decision wearing a component costume; a class or a styled element expresses it with one layer instead of three.
  • It happens for good reasons. Small-file guidance is real advice, applied without the counterweight. Design tools name every box, and the names look like a component list. Reviewers can measure file length and cannot measure cohesion, so the metric that gets optimised is the one that is visible.

What this makes the browser do

And which of it is avoidable.

  • A wrapper that renders nothing (a fragment) costs the browser exactly nothing and costs the framework one tree node per instance.
  • A wrapper that renders an element costs a DOM node, a style resolution, a box in the layout tree, and memory that lives as long as the subtree does (What a Mutation Costs).
  • Wrapper elements inside flex and grid containers are not neutral: the wrapper becomes the item, so the children you intended to lay out are now one level too deep and the layout has to be worked around (Flexbox: One Axis at a Time).
  • Deeper trees make selector matching more expensive for descendant selectors, since matching walks ancestors — usually irrelevant, occasionally the thing the profile is pointing at (Selector Matching Cost).
  • Framework work scales with tree node count, so in a component-per-element codebase a re-render evaluates several times as many functions to produce the same DOM (What a Component Costs to Render).

What ten frames to a button actually looks like

The tree below is not exaggerated; it is a lightly edited version of what accumulates in a codebase where every review comment says "this file is getting long". Read the right-hand column: the question is not whether each layer is small, but what each layer owns.

Two of the ten own something. Page owns the data and the route-level state; Button owns the semantics, the keyboard behaviour and the focus ring. The other eight are navigation cost, stack frames, and in four cases DOM nodes.

  • Four wrapper elements: real DOM nodes, real style resolutions, real boxes in the layout tree.
  • Four pass-through components: zero DOM, and every new prop must be threaded through all four.
  • One legitimate grouping (Section) that wanted children rather than a title prop and a body component.
  • Nine nodes per row. At five thousand rows that is forty-five thousand nodes where fifteen thousand would do (List Virtualization).
Page                     owns route state + data fetch      <- earns it
  Layout                 renders <div class="layout">         wrapper element
    Content              renders <div class="content">        wrapper element
      Section            forwards title, children             pass-through only
        SectionBody      renders <div class="body">           wrapper element
          Row            renders <div class="row">            wrapper element
            RowInner     forwards children                    pass-through only
              ActionArea forwards children                    pass-through only
                ButtonWrapper  forwards all props             pass-through only
                  Button owns role, keys, focus, disabled     <- earns it

Changing the button label:  5 files opened, 1 line changed
Adding one prop:            6 interfaces widened, 6 forwards added
DOM nodes for one row:      9 (design needs 3)
Stack trace on error:       10 frames, 8 of them scaffolding

Why it happens, and it is not carelessness

Every one of these layers was added by someone following a rule that is genuinely good advice in isolation. That is what makes the pattern durable: nobody involved was wrong at the time, and nobody had the counterweight written down.

Which means the fix is not more discipline. It is a second rule that pushes back — a component must own something — applied at the same moment the first rule is applied, in the same review.

The good reason behind each layer, and the counterweight
TriggerSymptomCauseResponse
"This file is over 200 lines"A cohesive component is split into files that only make sense read togetherLine count is measurable; cohesion is not, so the measurable one wins in reviewAsk what each new file owns. If the answer is "part of one job", it is one component (Drawing Component Boundaries).
"The design names every box"One component per named layer in the design toolDesign names are visual groupings, not behavioural unitsMap design names to classes and slots; reserve components for things that own behaviour.
"We might reuse this"A component with one call site and props for imagined variationExtracting on the first use guesses the interface with one exampleExtract on the second real call site, when the differences are visible.
"Wrap it so we can swap it later"A pass-through wrapper around a third-party component that adds nothingThe wrapper is a real idea implemented as a no-opKeep it and make it own the adaptation — the interface, the defaults, the accessibility fixes — or delete it.
"Everything should be testable in isolation"A test per wrapper asserting that children renderTestability was read as "mountable alone" rather than "has behaviour worth asserting"Test behaviour, not structure. A component with no behaviour needs no test (Component Testing).
"Barrel files make imports tidy"An index re-exporting everything; bundles include unused componentsRe-export barrels can defeat tree shaking depending on the bundler and the module formatImport from the module, and verify against the analyzer rather than assuming (Tree Shaking).

Collapsing without swinging to the other extreme

The correction is targeted, not total. Keep the boundaries that own something, replace the ones that exist only to group with slots and fragments, and express visual layers as classes. The result below has the same rendered output, the same styling hooks, and three fewer concepts.

The important detail is that the collapsed version is not "one big component". Page and Button are untouched, and Section survives — it just takes its title as content rather than as a string, which also hands the heading level back to the caller (Document Structure and Reading Order).

Ten layers, or four
Every grouping is a component
<Layout>
  <Content>
    <Section title="Invoices">
      <SectionBody>
        <Row>
          <RowInner>
            <ActionArea>
              <ButtonWrapper onClick={pay} label="Pay now" />
            </ActionArea>
          </RowInner>
        </Row>
      </SectionBody>
    </Section>
  </Content>
</Layout>
Groupings are markup; components own things
<div className="layout">
  <main className="content">
    <Section>
      <h2>Invoices</h2>
      <div className="row">
        <Button onClick={pay}>Pay now</Button>
      </div>
    </Section>
  </main>
</div>

Six layers disappear and nothing was lost, because none of the six owned state, a decision or a behaviour. What remains is legible in one screen, five DOM nodes lighter per row, and the heading level is now the caller's to choose rather than fixed inside a component that cannot know where it is rendered.

How to build it

Most important first.

  • Require a component to own something: state, a decision, a behaviour, a semantic pattern, or two real call sites. "It makes the file shorter" is not ownership.
  • Collapse pass-through-only components. If a component declares props it does not read, either it should read them or it should not exist.
  • Use fragments rather than wrapper elements when you need a grouping and not a box. A grouping that produces no DOM node costs the browser nothing.
  • Express visual variation with classes, custom properties or a styled element before expressing it with a component. One layer of indirection instead of three (Custom Properties).
  • Let a component be long when its job is long. A four-hundred-line form that is one form is easier to change than eleven files that are one form (Drawing Component Boundaries).
  • Use children instead of a Wrapper that takes a content prop. The wrapper exists because content had nowhere to go (Composition and Slots).
  • Delete on sight during review: any component whose render body is a single element with {children} inside and no logic, unless it is a genuine design-system primitive with a documented contract.
  • Be sympathetic about it in review. This is a mistake made by people following advice, not by people being careless, and the review comment should name the missing ownership rather than the file count.

Keyboard, focus, semantics, announcement

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

  • Wrapper elements can break parent-child role requirements. listitem inside anything other than a list, option outside listbox, tab outside tablist — the visual output is unchanged and the accessibility tree is not (The Rules of ARIA).
  • Deep trees make ownership of the label, the role and the focus unclear, and unclear ownership means it lands nowhere. The most common casualty is the accessible name (What a Component Owes Its Caller).
  • A wrapper is where a div gets introduced around a control. If that wrapper later gets a click handler for convenience, there is now a clickable non-focusable element around a focusable one (Div Soup: How It Happens and What It Costs).
  • Focus management fragments: if the component that owns the interaction is four layers above the one that owns the element, moving focus requires forwarding a ref through every layer, and one missing forward breaks it silently (Focus Management).
  • Excess nesting can change how screen readers group and announce content, especially where landmarks or lists are involved. The reading experience is affected by structure the visual design does not show.
  • On the other side: a well-drawn boundary that owns a whole pattern is one of the strongest accessibility tools available. This lesson is against boundaries that own nothing, not against boundaries (Accessible Component Patterns).

What can go wrong

Failure modes
  • The rename that only renamed one layer, so ActionArea now contains ButtonWrapper which contains LegacyButton, and nobody is sure which of the three is used.
  • The prop that stopped being forwarded at layer six, so a feature works in one call path and silently does nothing in another.
  • The default that was applied twice: a wrapper and its child both apply the same padding, and the fix is a negative margin somewhere in the middle.
  • The abstraction that grew a special case, then another, until the shared component has three boolean props that each mean "do not do the thing this component was for".
  • The mitigation failing: a "flattening" refactor that inlines everything and produces a two-thousand-line component nobody wants to touch. The correction for too many boundaries is not zero boundaries.
  • Wrappers that break required ARIA ancestry — a MenuItemWrapper between menu and menuitem — so the roles no longer form the structure assistive technology expects (The Rules of ARIA).
What can arrive out of order
  • A ref forwarded through many layers is attached bottom-up, so a parent effect that runs on mount may find the ref still empty if any layer forwards it conditionally or a frame late (Focus Management).
  • Effects in a deep tree run child-first on mount and parent-first on some updates, so scaffolding layers that quietly added an effect change the ordering of the effects that actually mattered.
Security
  • Each forwarding layer is a place a sanitisation step can be skipped. A component that spreads {...rest} onto the next layer, four times, means nobody is checking what is in rest (Cross-Site Scripting).
  • Deep pass-through makes it hard to see what reaches a dangerous sink. The distance between "the caller passed a string" and "something set innerHTML" is where review misses things (Sanitization and Trusted HTML).
  • More files and more re-export barrels make the dependency graph harder to audit, and an index barrel that re-exports everything defeats tree shaking, shipping more code than the page uses (Tree Shaking).
  • Nothing here is a vulnerability by itself. The mechanism is that indirection reduces reviewability, and reduced reviewability is where the real ones survive.
Misreads
  • "So components are bad." Boundaries drawn where something is owned are the whole point of the module. This is about boundaries that own nothing (Drawing Component Boundaries).
  • "Long files are fine then." Long files that do one thing are fine. Long files that do six things are the other failure mode and it is just as real.
  • "The stack depth is only a debugging annoyance." It is also framework work per render, DOM nodes per instance, and bundle bytes per barrel. Small numbers, multiplied.
  • "Wrappers are free because fragments render nothing." Fragments are free in the DOM and are still tree nodes, files, imports and stack frames. The cost that hurts is not the element.
  • "This is a style preference, so it does not belong in review." It shows up in files-touched-per-change, in profiles, and in node counts. Preferences do not do that.

Measuring it, and what changes in the field

How you would see this
  • Tree depth from route root to a leaf interactive element in framework devtools. A double-digit path with single-digit stateful nodes is the shape of this problem.
  • The pass-through ratio: props declared versus props read, per component. It is computable with a script and it is the sharpest single number here.
  • DOM node count for one list row, against what the design needs. The difference is wrapper tax and it multiplies by row count (What a Mutation Costs).
  • Components re-evaluated per interaction in a profiler recording, against components that produced a DOM change. A large gap is framework work spent on scaffolding (What a Component Costs to Render).
  • Median files touched per pull request over a few months. A trend upward with no change in feature size is the maintenance cost showing up in the data.
  • Bundle composition by directory. A component directory that is mostly barrels and wrappers is shipping structure rather than behaviour (Bundle Analysis).
Slow device, slow network, large data, old tab
  • On a slow device, the extra framework work and extra nodes stop being negligible — not because any one wrapper is expensive, but because there are hundreds (The Real Cost of JavaScript).
  • In long lists, per-row wrappers multiply directly. This is the one place where over-componentization is a straightforward performance bug (List Virtualization).
  • On a large team, the navigation cost dominates the runtime cost by a wide margin. The expensive part is the hour spent finding the file, every time, by everyone.
  • In a design system, some indirection is genuinely load-bearing — a primitive exists so that a token change lands everywhere. The rule is that it must own the decision, not merely relay it (Design Tokens).
  • With server components, extra client-side wrappers can pull a subtree across the server/client boundary that did not need to cross it, and that changes what ships (Server Components).
What this costs
  • Collapsing components produces longer files, and longer files genuinely are harder to skim. You are trading skim-ability for locality, and locality is what makes changes safe.
  • Some pass-through layers are deliberate: a boundary that exists to keep an import direction clean, or to isolate a third-party component behind a stable interface. Those own something — a dependency decision — and should be kept and labelled.
  • A flattening refactor touches many files and reviews badly, which is why it rarely happens. Doing it opportunistically, one collapsed wrapper per feature branch, is slower and actually gets done.
  • The rule "a component must own something" is a judgement call, and judgement calls are harder to enforce in review than a line-count limit. That is the honest cost of the better rule.

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.

  • GENERALThe comprehension and navigation costs are framework-independent; they follow from indirection itself and would apply just as much to functions, modules or partials in a codebase with no components at all.
  • FRAMEWORK-SPECIFICThe runtime cost is not portable. React and Vue evaluate per component, so wrapper count is per-render work; Svelte and Solid compile away much of the component boundary, so a wrapper costs a tree node and very little execution. A depth that measurably hurts in one framework can be free in another, while the debugging cost is identical in both.

Where the depth lives

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

Architecturemodular-monolith
Domains that do not exist yet
  • Software Design — coupling and cohesion, and the observation that indirection is only worth its cost when it hides a decision. A layer that hides nothing is a layer that only forwards.
  • Testing & Reliability Engineering — structural tests that assert children render are tests of the framework, and they raise the cost of every future refactor without lowering the risk of one.