Build CoreOrdered learning track

Composition Failure Modes

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 030

Composition failure modes in React: prop explosion, wrapper hell, hidden context coupling, provider pyramids, accidental remounts, fragile children manipulation, render-prop misuse, polymorphic semantics bugs, compound component hazards, and refactor recipes.

8 min read1503 words
PrevNext
Lesson 30123 lesson track24–67 Build Core
#react#component-composition#failure-modes#frontend-architecture+3 more

Part 030 — Composition Failure Modes

Composition adalah salah satu kekuatan terbesar React.

Tetapi composition juga bisa menjadi sumber kompleksitas terbesar ketika boundary tidak jelas.

Masalahnya jarang terlihat sebagai error syntax. Masalahnya muncul sebagai:

UI unexpectedly remounts
state resets when layout changes
component API becomes impossible to understand
children order matters but nobody documents it
context dependency is hidden
slots leak implementation details
provider tree becomes unmaintainable
render callbacks become stale or impure
accessibility breaks when component is composed differently

Part ini adalah failure-mode map untuk Module 2.

Tujuannya bukan membuat takut memakai composition. Tujuannya membuat composition bisa dipakai secara defensible di codebase besar.


1. Failure Mode Map

A production React engineer should recognize these before they become architecture debt.


2. Prop Explosion

Prop explosion happens when a component tries to support all variations through flat props.

<Card
  title="Case"
  subtitle="Pending review"
  showIcon
  icon="warning"
  showActions
  primaryActionLabel="Approve"
  onPrimaryAction={approve}
  secondaryActionLabel="Reject"
  onSecondaryAction={reject}
  showFooter
  footerText="Updated today"
  compact
  bordered
  elevated
/>

This feels convenient at first.

Then requirements arrive:

custom action menu
action loading state
action permission tooltip
secondary action confirmation
custom footer content
audit badge in header
responsive header layout

The prop surface becomes a configuration language.

Better:

<Card compact bordered elevated>
  <Card.Header
    title="Case"
    subtitle="Pending review"
    icon={<WarningIcon />}
    actions={<CaseActionMenu caseId={caseId} />}
  />
  <Card.Body>
    <CaseSummary caseRecord={caseRecord} />
  </Card.Body>
  <Card.Footer>
    <LastUpdated timestamp={caseRecord.updatedAt} />
  </Card.Footer>
</Card>

Refactor rule:

When variation becomes structural, replace props with composition.

3. Configuration Language Disguised as Component API

Some components expose config objects that become mini programming languages.

<Table
  config={{
    columns: [
      {
        field: 'status',
        type: 'badge',
        colorMap: { open: 'blue', closed: 'green' },
        tooltip: true,
        permission: 'case.viewStatus',
      },
    ],
    actions: [
      {
        type: 'button',
        label: 'Approve',
        visibleWhen: 'case.status === pending',
        confirm: true,
      },
    ],
  }}
/>

This might be valid for metadata-driven UI.

But if this component lives in normal React product code, it can become worse than JSX:

less type-safe
less debuggable
less composable
harder to test
harder to integrate with hooks
harder to express custom behavior

Rule:

Do not build a configuration language unless configuration is truly the product requirement.

For normal UI, JSX is already the composition language.


4. Wrapper Hell

Wrapper hell happens when every variation creates another component wrapper.

<PermissionWrapper permission="case.approve">
  <TooltipWrapper content="Approve this case">
    <TrackingWrapper event="approve_clicked">
      <ConfirmWrapper message="Are you sure?">
        <Button onClick={approve}>Approve</Button>
      </ConfirmWrapper>
    </TrackingWrapper>
  </TooltipWrapper>
</PermissionWrapper>

The UI still works, but control flow becomes unreadable.

Problems:

callback path is hard to trace
focus behavior may break
DOM structure becomes deeper than intended
styling selectors become fragile
testing requires too many providers/wrappers

Better: compose capability into a domain-level component.

<CaseApproveAction
  caseId={caseId}
  capabilities={capabilities}
  onApprove={approve}
/>

Internally:

function CaseApproveAction({ caseId, capabilities, onApprove }: Props) {
  const disabledReason = getApproveDisabledReason(capabilities)

  return (
    <ConfirmAction
      message="Approve this case?"
      disabledReason={disabledReason}
      trackingEvent="approve_clicked"
      onConfirm={onApprove}
    >
      Approve
    </ConfirmAction>
  )
}

Wrapper hell often means the composition level is too low for the use case.


5. Leaky Abstraction

A component leaks abstraction when caller must know its internals to use it correctly.

Example:

<Modal className="modal-root">
  <div className="modal-header">...</div>
  <div className="modal-body">...</div>
</Modal>

Caller must know class names and structure.

Better:

<Modal open={open} onOpenChange={setOpen}>
  <Modal.Content>
    <Modal.Header>
      <Modal.Title>Reject case</Modal.Title>
    </Modal.Header>
    <Modal.Body>
      <RejectForm />
    </Modal.Body>
  </Modal.Content>
</Modal>

The component now exposes intentional parts.

Leaky abstraction smells:

caller must use specific class names
caller must insert hidden DOM nodes
caller must know child order but API doesn't express it
caller must pass internal ids manually
caller must call internal methods to keep UI working

6. Ambiguous Slots

Named slots help, but ambiguous slots create bugs.

<PageLayout
  top={<Toolbar />}
  left={<FilterPanel />}
  right={<DetailsPanel />}
/>

What does top mean?

page header?
sticky toolbar?
above content?
inside scroll region?
visible on mobile?

Better:

<PageLayout
  header={<CaseSearchHeader />}
  sidebar={<CaseFilterPanel />}
  detailPanel={<CaseDetailsPanel />}
/>

Slot names should reflect semantic region, not implementation position.

Bad slot names:

top
bottom
left
right
extra
custom
content2

Better slot names:

header
actions
sidebar
filters
detailPanel
emptyState
footer
primaryAction
secondaryAction

7. Hidden Context Coupling

Context is useful, but context can make component dependencies invisible.

function CaseActionButton() {
  const caseRecord = useCaseContext()
  const permissions = usePermissionContext()
  const audit = useAuditContext()

  return <button>Approve</button>
}

The component call site looks simple:

<CaseActionButton />

But real dependency graph is hidden:

CaseProvider must exist
PermissionProvider must exist
AuditProvider must exist
provider values must be compatible
component cannot be reused outside that tree

Better for domain component:

<CaseActionButton
  caseRecord={caseRecord}
  capabilities={capabilities}
  onAudit={recordAudit}
/>

Or make context dependency explicit at a boundary:

function ConnectedCaseActionButton() {
  const caseRecord = useCaseContext()
  const capabilities = useCaseCapabilities()
  const audit = useAudit()

  return (
    <CaseActionButton
      caseRecord={caseRecord}
      capabilities={capabilities}
      onAudit={audit.record}
    />
  )
}

Rule:

Use context freely at orchestration boundaries.
Keep reusable leaf components explicit when possible.

8. Provider Pyramid

Provider pyramid happens when every concern adds a provider at the root.

<AuthProvider>
  <ThemeProvider>
    <FeatureFlagProvider>
      <PermissionProvider>
        <CaseProvider>
          <SearchProvider>
            <TableProvider>
              <ModalProvider>
                <App />
              </ModalProvider>
            </TableProvider>
          </SearchProvider>
        </CaseProvider>
      </PermissionProvider>
    </FeatureFlagProvider>
  </ThemeProvider>
</AuthProvider>

Not all provider nesting is bad.

It becomes bad when:

provider scope is much wider than needed
provider value changes frequently
providers depend on each other implicitly
component tests require full app provider tree
feature-level concerns are globalized

Refactor:

<AppShell>
  <Routes>
    <Route
      path="/cases"
      element={
        <CaseSearchProviders>
          <CaseSearchPage />
        </CaseSearchProviders>
      }
    />
  </Routes>
</AppShell>

Provider rule:

Global provider only for global concern.
Feature provider for feature concern.
Component provider for component coordination.

9. Context Dumping Ground

A context becomes a dumping ground when unrelated values are bundled together.

type AppContextValue = {
  user: User
  theme: Theme
  cases: CaseRecord[]
  selectedCaseId: string | null
  setSelectedCaseId: (id: string | null) => void
  modalOpen: boolean
  setModalOpen: (open: boolean) => void
  notifications: Notification[]
}

Problems:

unrelated updates rerender unrelated consumers
ownership becomes unclear
testing one component requires huge fake context
feature boundaries blur

Better:

AuthContext
ThemeContext
CaseSearchContext
ModalManagerContext
NotificationStore

Even better: not all shared state needs context. Some should be external store, URL state, server cache, or local lifted state.

Context is dependency injection.

It is not a landfill.


10. Accidental Remount

Composition can accidentally change component identity.

Example:

function Page({ compact }: { compact: boolean }) {
  return compact ? (
    <CompactLayout>
      <SearchForm />
    </CompactLayout>
  ) : (
    <FullLayout>
      <SearchForm />
    </FullLayout>
  )
}

SearchForm appears in both branches, but its position/type path changes.

Its state may reset.

Refactor:

function Page({ compact }: { compact: boolean }) {
  const Layout = compact ? CompactLayout : FullLayout

  return (
    <Layout>
      <SearchForm />
    </Layout>
  )
}

Still be careful: if Layout identity changes, subtree behavior depends on where children are placed.

More explicit:

function Page({ compact }: { compact: boolean }) {
  return (
    <ResponsiveLayout compact={compact}>
      <SearchForm />
    </ResponsiveLayout>
  )
}

Rule:

Changing composition structure can change state identity.
Review remount behavior when layout branches change.

11. Unstable Keys

Keys are part of identity.

Bad:

items.map((item, index) => <Row key={index} item={item} />)

If items reorder, state follows position, not entity.

Better:

items.map((item) => <Row key={item.id} item={item} />)

Composition bug example:

<Tabs>
  {tabs.map((tab, index) => (
    <Tabs.Content key={index} value={tab.value}>
      <ExpensiveForm />
    </Tabs.Content>
  ))}
</Tabs>

If tabs reorder, form state can attach to wrong tab.

Use stable identity:

<Tabs>
  {tabs.map((tab) => (
    <Tabs.Content key={tab.value} value={tab.value}>
      <ExpensiveForm />
    </Tabs.Content>
  ))}
</Tabs>

If you want reset, make it explicit:

<CaseEditor key={caseId} caseId={caseId} />

Identity should be a design choice, not an accident.


12. Inline Component Definitions

Defining components inside components can accidentally reset state because the child component function identity changes per render.

Bad:

function CasePage() {
  function Toolbar() {
    return <button>Refresh</button>
  }

  return <Toolbar />
}

Better:

function Toolbar() {
  return <button>Refresh</button>
}

function CasePage() {
  return <Toolbar />
}

If you need closure values, pass props.

function Toolbar({ onRefresh }: { onRefresh: () => void }) {
  return <button onClick={onRefresh}>Refresh</button>
}

This is not only about performance. It is about component identity and Hook correctness.


13. Fragile Children Manipulation

React allows inspecting and transforming children, but it is easy to create fragile code.

Example:

function RowList({ children }: { children: React.ReactNode }) {
  return React.Children.map(children, (child) => (
    <div className="row">{child}</div>
  ))
}

This may work, but callers may not expect their children to be wrapped or reordered.

More fragile:

function Tabs({ children }: Props) {
  const triggers = React.Children.toArray(children).filter(isTrigger)
  const contents = React.Children.toArray(children).filter(isContent)
  // implicit parsing logic
}

Problems:

fragments may behave unexpectedly
conditional children complicate parsing
component identity can be affected
TypeScript cannot strongly guarantee child element types
caller order dependencies become implicit

Prefer explicit compound component contracts with context, or explicit props.

<Tabs>
  <Tabs.List>...</Tabs.List>
  <Tabs.Content value="overview">...</Tabs.Content>
</Tabs>

Avoid clever child parsing unless there is a strong reason.


14. cloneElement Overuse

cloneElement can inject props into children.

React.cloneElement(child, { active: true })

But overuse causes hidden contracts.

Caller writes:

<Menu>
  <Button>Open</Button>
</Menu>

But Menu silently expects child to accept:

onClick
aria-expanded
ref
id
data-state

If child does not forward props/ref correctly, behavior breaks.

Better options:

compound parts
prop getter pattern
asChild with documented requirements
explicit render function

Example prop getter:

const triggerProps = menu.getTriggerProps()

<button {...triggerProps}>Open</button>

Hidden injection is powerful but must be treated as advanced API.


15. Render Prop Hook Misuse

Render props are callbacks.

They are not components.

Bad:

<DataProvider>
  {(data) => {
    const [selected, setSelected] = React.useState(null)
    return <View data={data} selected={selected} />
  }}
</DataProvider>

This violates Hook rules because the callback is not a React component or custom Hook.

Better:

<DataProvider>
  {(data) => <DataView data={data} />}
</DataProvider>

function DataView({ data }: { data: Data }) {
  const [selected, setSelected] = React.useState<string | null>(null)
  return <View data={data} selected={selected} onSelectedChange={setSelected} />
}

Render prop contract:

callback must be pure rendering function
callback must not call Hooks
callback must not perform side effects
callback should not depend on hidden invocation order

16. Polymorphic Semantic Breakage

The as prop is powerful.

It can also break semantics.

Bad:

<Button as="div" onClick={submit}>Submit</Button>

Now it looks like a button but may not behave like one:

keyboard activation may be wrong
form submission semantics disappear
disabled semantics disappear
screen reader semantics change

Better:

<Button type="submit">Submit</Button>

Or restrict polymorphism:

type ButtonAs = 'button' | 'a'

For product primitives, freedom is not always good.

Rule:

Polymorphism must preserve semantic contract.

17. Compound Component Order Hazards

Compound APIs can hide order requirements.

<Select>
  <Select.Option value="a">A</Select.Option>
  <Select.Trigger />
</Select>

Does this work?

If not, does the API tell the caller?

Order hazards appear when internal registration depends on render order or effect timing.

Safer API:

<Select>
  <Select.Trigger />
  <Select.Content>
    <Select.Option value="a">A</Select.Option>
  </Select.Content>
</Select>

Add runtime guard in development:

function useSelectContext(componentName: string) {
  const context = React.useContext(SelectContext)

  if (!context) {
    throw new Error(`${componentName} must be used inside <Select>.`)
  }

  return context
}

If order matters, document it and test it.

Better: design so order matters as little as possible.


18. Double Source of Truth

Composition often creates duplicate state ownership.

function CasePage() {
  const [selectedCaseId, setSelectedCaseId] = React.useState<string | null>(null)

  return (
    <CaseTable
      selectedCaseId={selectedCaseId}
      defaultSelectedCaseId="case-1"
      onSelectedCaseChange={setSelectedCaseId}
    />
  )
}

This mixes controlled and uncontrolled semantics.

Another example:

<SearchBox value={query} />

But inside:

function SearchBox({ value }: Props) {
  const [inputValue, setInputValue] = React.useState(value)
  // attempts to sync with effect
}

This creates drift.

Better:

function SearchBox({ value, onValueChange }: Props) {
  return <input value={value} onChange={(e) => onValueChange(e.target.value)} />
}

Or explicit local draft:

function SearchBox({ committedValue, onCommit }: Props) {
  const [draft, setDraft] = React.useState(committedValue)

  return (
    <form onSubmit={() => onCommit(draft)}>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
    </form>
  )
}

Duplicate state is allowed only when it has a different lifecycle.


19. Orphaned Callback

An orphaned callback fires but no one owns the result.

<DeleteButton onDelete={() => deleteCase(caseId)} />

Questions:

Who closes the dialog?
Who updates cache?
Who handles error?
Who shows pending state?
Who prevents double click?
Who tracks audit event?

If callback is domain command, wrap it in command state.

<DeleteCaseDialog
  open={open}
  onOpenChange={setOpen}
  command={deleteCaseCommand}
/>

Where:

type Command = {
  execute: () => Promise<void>
  isPending: boolean
  error?: Error
  disabledReason?: string
}

Or keep ownership explicit:

<DeleteCaseDialog
  open={open}
  onOpenChange={setOpen}
  isDeleting={deleteMutation.isPending}
  error={deleteMutation.error}
  onConfirmDelete={handleConfirmDelete}
/>

A callback should not be the only representation of an async workflow.


20. Effects Used to Repair Bad Composition

A common smell:

useEffect(() => {
  onChange(internalState)
}, [internalState, onChange])

This often means state ownership is wrong.

If the change is caused by an event, call callback in the event handler.

Bad:

function Toggle({ onCheckedChange }: Props) {
  const [checked, setChecked] = React.useState(false)

  React.useEffect(() => {
    onCheckedChange(checked)
  }, [checked, onCheckedChange])

  return <button onClick={() => setChecked(!checked)} />
}

Better:

function Toggle({ onCheckedChange }: Props) {
  const [checked, setChecked] = React.useState(false)

  function toggle() {
    setChecked((current) => {
      const next = !current
      onCheckedChange(next)
      return next
    })
  }

  return <button onClick={toggle} />
}

Even better for controlled API:

function Toggle({ checked, onCheckedChange }: Props) {
  return <button onClick={() => onCheckedChange(!checked)} />
}

Effects synchronize with external systems. They should not patch unclear component contracts.


21. Unbounded Composition

Sometimes an API accepts anything.

<Dialog>{children}</Dialog>

This is flexible, but too flexible when behavior requires structure.

Dialog needs:

title
description
content region
close control
focus target

Unbounded children make it easy to create inaccessible dialogs.

Better:

<Dialog open={open} onOpenChange={setOpen}>
  <Dialog.Content>
    <Dialog.Title>Reject case</Dialog.Title>
    <Dialog.Description>Provide a reason.</Dialog.Description>
    <RejectForm />
  </Dialog.Content>
</Dialog>

Freedom should match risk.

For low-risk layout, children is fine.

For high-risk behavior, expose structured parts.


22. Composition Across Permission Boundaries

Permission logic is often composed badly.

Bad:

{canApprove && <ApproveButton onClick={approve} />}

This hides the button entirely.

Maybe correct. Maybe not.

In enterprise/regulatory UI, disabled-with-reason may be required:

<ApproveButton
  disabledReason={approveDisabledReason}
  onApprove={approve}
/>

The composition decision must encode product and audit semantics:

hidden        → user should not know capability exists
visible disabled → user may know action exists but cannot use it
visible enabled  → user can execute action
visible read-only explanation → action blocked by workflow state

Do not bury this inside random conditionals across the tree.

Use a permission-aware composition boundary.

<CaseActions
  capabilities={capabilities}
  workflowState={caseRecord.workflowState}
  onApprove={approve}
  onReject={reject}
/>

23. Composition and Accessibility Drift

Accessibility often breaks during composition refactors.

Example:

<Tabs.Trigger asChild>
  <div>Audit</div>
</Tabs.Trigger>

If div does not implement button semantics, tab behavior may degrade.

Another example:

<FormField>
  <CustomInput />
  <FormField.Error />
</FormField>

If CustomInput does not forward id, aria-describedby, and aria-invalid, the label/error relationship breaks.

Composition must preserve accessibility contract.

Checklist:

Does the composed element keep the correct role?
Does it forward required props?
Does it forward ref?
Does keyboard behavior still work?
Does disabled state remain semantic?
Are label/description/error ids connected?

24. Composition Testing Blind Spots

Snapshot tests are weak for composition.

They often confirm markup shape but miss behavior.

Better tests:

contract tests
keyboard interaction tests
controlled/uncontrolled tests
provider-missing tests
composition variant tests
accessibility relationship tests
remount/state preservation tests

Example test scenarios for compound Tabs:

throws helpful error when Trigger used outside Tabs
activates tab on click
supports keyboard navigation
preserves content state when configured to keep mounted
resets content state when configured to unmount
works in controlled mode
works in uncontrolled mode

Test public behavior, not internal implementation.


25. Failure Mode Diagnosis Table

SymptomLikely Failure ModeFirst Refactor
Props keep growingProp explosionReplace structural props with slots/compound parts
Component unusable outside one pageHidden context couplingSplit connected wrapper from pure component
State resets on layout changeAccidental remountStabilize component identity/tree position
Child must be specific type but API accepts anythingUnbounded compositionExpose compound parts or runtime guards
Accessibility breaks with custom childPolymorphic/ref forwarding issueRestrict as, add prop getter, require ref forwarding
Tests require full app setupProvider pyramidScope providers and create test harnesses
Component has loading/error but caller also has loading/errorDouble source of truthMove async state ownership to one boundary
Callback fires too often or at wrong timeEvent contract ambiguityDistinguish intent, notification, lifecycle callbacks
CSS overrides break after refactorLeaky styling contractExpose stable className/data-state/CSS variable hooks
Config object contains behavior stringsConfiguration languageUse JSX composition or explicit domain model

26. Refactor Recipe: From Failure to Boundary

When composition feels messy, do not immediately add another abstraction.

Use this sequence:

1. Identify the state owner.
2. Identify the behavior owner.
3. Identify whether variation is data-like or structural.
4. Identify required accessibility semantics.
5. Identify identity/reset expectations.
6. Split connected component from reusable component.
7. Replace accidental props with intentional slots.
8. Add runtime guards for invalid composition.
9. Add contract tests.

Example:

<CasePageHeader
  showExport
  showCreate
  exportDisabled
  createDisabled
  onExport={exportCases}
  onCreate={createCase}
/>

Refactor:

<PageHeader title="Cases">
  <PageHeader.Actions>
    <ExportCasesButton
      disabledReason={exportDisabledReason}
      onExport={exportCases}
    />
    <CreateCaseButton
      disabledReason={createDisabledReason}
      onCreate={createCase}
    />
  </PageHeader.Actions>
</PageHeader>

Now action complexity moves to action components, not header config.


27. Defensive Runtime Guards

TypeScript cannot catch every composition misuse.

Examples:

component used outside provider
required compound part missing
invalid child structure
controlled prop without callback
switching controlled/uncontrolled mode
asChild child does not accept ref

Add development-only guards.

function invariant(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(message)
  }
}

Usage:

function TabsTrigger(props: Props) {
  const context = React.useContext(TabsContext)
  invariant(context, 'Tabs.Trigger must be used inside <Tabs>.')

  // ...
}

Good guard messages are part of developer experience.

Bad:

Cannot read property value of null

Good:

Tabs.Trigger must be rendered inside <Tabs>. Move it under <Tabs> or use the headless useTabs hook directly.

28. Composition Review Checklist

Before approving a composition abstraction, ask:

What behavior is being composed?
What state is being shared?
Who owns that state?
Does composition preserve component identity?
Are keys stable and entity-based?
Are slots semantic or positional?
Does component require specific children?
Is that requirement enforced?
Does the API leak internal DOM/class structure?
Does context hide dependencies?
Is provider scope minimal?
Can this component be tested without full app tree?
Does polymorphism preserve semantic HTML?
Does ref forwarding work where required?
Does accessibility survive custom composition?
Are async commands represented as state, not only callbacks?
Are illegal states prevented?

Composition should reduce local complexity without increasing global ambiguity.


29. Module 2 Summary

Module 2 covered component composition as architecture:

composition over configuration
modern container/presentational boundaries
controlled/uncontrolled ownership
compound components
render props
headless components
polymorphic components
component contracts
public API design
composition failure modes

The deeper lesson:

Composition is not "put JSX inside JSX".
Composition is how React systems define ownership, extension points, identity, behavior, and semantic guarantees.

A strong React engineer does not ask only:

Can this component be reused?

They ask:

What contract does reuse create?
What invalid states does the API prevent?
What behavior is hidden or exposed?
What composition paths are safe?
What failure modes become impossible?

That is the difference between reusable UI and scalable UI architecture.


30. Transition to Module 3

The next module is Context Passing.

Composition eventually reaches a limit:

props become deeply threaded
component groups need shared coordination
capabilities must be passed across distance
feature boundaries need dependency injection

Context is one answer.

But context is not a global state manager by default.

The next parts will treat context as a precision tool:

dependency injection
capability passing
compound component coordination
provider boundary design
performance containment
anti-pattern avoidance

The goal is not to use more context.

The goal is to use context only where it creates a cleaner dependency boundary than props or store.

Lesson Recap

You just completed lesson 30 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.