Today’s goal
Learn to decide where a component should begin and end.
We will connect:
- responsibility and reasoning boundaries;
- inputs, outputs, props, events, and slots;
- controlled and uncontrolled state;
- compound and headless components;
- context and dependency injection;
- reuse, abstraction, and duplication;
- feature, layer, and domain-oriented organization;
- accessibility, performance, internationalization, and security.
By the end of today you can
- identify a coherent component responsibility;
- distinguish a component boundary from a state boundary;
- design intent-oriented public APIs;
- choose controlled or uncontrolled ownership deliberately;
- compose behavior without forcing styling decisions;
- use context or injection without hiding dependencies;
- recognize oversized and over-fragmented components;
- extract components in a safe sequence;
- organize a front-end by feature, layer, or domain;
- refactor a catalogue into a maintainable component architecture.
Testing boundaries follow behavior
A date picker can be tested for:
- selected-date behavior;
- keyboard navigation;
- disabled dates;
- accessible labels.
The surrounding page should not have to reproduce every internal interaction to test the date picker.
Test where behavior and risk are concentrated - not merely where files happen to exist.
Bad extreme: one giant component
CataloguePage
search state
filter state
fetch logic
URL synchronization
product rendering
cart events
modal behavior
analytics
keyboard handling
responsive layoutTypical symptoms:
- everything can reach everything;
- a small change creates a large regression surface;
- tests require excessive setup;
- state ownership is ambiguous.
What owns the behavior?
SearchInput owns input interaction
SearchController owns query coordination
ProductGrid owns product layout
ProductCard owns product presentationDo not move state merely because a child renders the element.
The owner is the unit that makes the decision and coordinates its consequences.
What should remain private?
Not every internal helper needs to become a public component.
Keep implementation details local when they:
- have no independent responsibility;
- are used in one place;
- would expose unstable structure;
- make the public API harder to understand.
Local components are valid architecture.
A deliberately monolithic shape
function CataloguePage() {
const [query, setQuery] = useState("");
const [filters, setFilters] = useState(defaultFilters);
const { data, loading, error } = useProducts(query, filters);
return (
<main>
{/* search, filters, cards, pagination, errors, and cart */}
</main>
);
}The problem is not that it renders JSX.
The problem is that unrelated responsibilities have no visible boundaries.
A component boundary is not necessarily a state boundary
ProductCard renders one product
CataloguePage owns selected products and filters
CartStore owns basket stateA child can be a useful rendering boundary while state remains higher in the tree.
Do not force every component to own every value it displays.
Avoid half-controlled APIs
<Tabs value={value} defaultValue="overview" onChange={setValue} />What wins if value is absent? What happens when it appears later?
Ambiguous ownership creates warnings, stale state, and surprising transitions.
Use separate APIs or document the controlled/uncontrolled contract precisely.
Good component APIs minimize invalid combinations
Instead of:
disabled + loading + error + success + compact + outline + danger + iconOnlyModel meaningful states and relationships.
type SubmitButtonProps =
| { status: "ready"; onSubmit: () => void }
| { status: "loading" }
| { status: "error"; message: string; onRetry: () => void };Compound components share a local vocabulary
<Tabs>
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="reviews">Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">...</Tabs.Panel>
<Tabs.Panel value="reviews">...</Tabs.Panel>
</Tabs>The pieces are separate in markup but belong to one conceptual system.
The cost of compound components
They also add:
- hidden coordination rules;
- context or injection dependencies;
- more concepts to document;
- more invalid combinations to prevent;
- debugging work when pieces are used incorrectly.
Use the pattern when the relationship is real and repeated - not because the API looks advanced.
Dependency sharing: explicit props first
<ProductCard
product={product}
currency={currency}
locale={locale}
onAddToCart={onAddToCart}
/>Explicit inputs make dependencies visible and make the component easy to render in isolation.
Prop passing becomes a problem only when it is genuinely burdensome or crosses unrelated layers repeatedly.
React context shares a local dependency
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) throw new Error("Tabs parts must be inside Tabs");
return context;
}Context can keep compound parts coordinated without repeating the same props at every level.
Reusable UI components versus application components
Reusable: Button, Dialog, Tabs, Field
Application: ProductCard, CatalogueFilters, ApprovalPanelReusable UI components should avoid application-specific assumptions.
Application components should speak the domain language and may coordinate several reusable primitives.
Duplication can be cheaper than the wrong abstraction
Two similar components may differ in:
- ownership;
- accessibility requirements;
- lifecycle;
- domain vocabulary;
- future change direction.
Temporary duplication preserves independent evolution.
Remove duplication when the shared concept - not only the current markup - is real.
Design methodologies are lenses, not laws
Atomic design, feature folders, layers, and domain modules can all be useful.
None can decide a boundary without understanding:
- change patterns;
- ownership;
- dependencies;
- product vocabulary;
- team constraints.
Use a methodology to ask better questions, not to avoid judgment.
Atomic design: strength and limitation
Atomic design encourages a vocabulary from primitives to composed interfaces.
It can help teams discover reusable visual patterns.
But visual size does not always match responsibility.
An “organism” may be a domain feature, while a tiny “atom” may still contain complex behavior.
Avoid boolean prop explosion
<Button primary compact rounded loading danger iconOnly />Many booleans create a combinatorial API and states nobody designed.
Prefer meaningful variants or modeled states:
type ButtonVariant = "primary" | "danger" | "quiet";
type ButtonState = "ready" | "loading" | "disabled";Stable components should not depend on the whole application
A reusable component should not know:
- the entire route tree;
- the global store shape;
- the current user object;
- every feature’s analytics policy.
Pass the smallest data and capabilities needed.
Application coordination belongs above the reusable boundary.
React catalogue architecture
function CataloguePage() {
const state = useCatalogueState();
return (
<CatalogueLayout>
<SearchControls value={state.query} onChange={state.setQuery} />
<ProductGrid products={state.products} onAdd={state.addToCart} />
</CatalogueLayout>
);
}The page coordinates. The children expose focused responsibilities.
React product grid
function ProductGrid({ products, onAdd }: ProductGridProps) {
return (
<ul className="product-grid">
{products.map(product => (
<li key={product.id}>
<ProductCard product={product} onAddToCart={onAdd} />
</li>
))}
</ul>
);
}The grid owns collection layout.
The card owns one product’s presentation and interaction surface.
Recognize an oversized component
Warning signs:
- many unrelated state variables;
- long conditional render branches;
- repeated markup with slightly different behavior;
- effects that coordinate unrelated systems;
- tests that require the entire application setup;
- a name that no longer describes one responsibility.
Extract by responsibility, not by line count alone.
Recognize over-fragmentation
Warning signs:
- a component has no meaningful API;
- understanding one behavior requires many file jumps;
- props are forwarded unchanged through several layers;
- every markup element has its own file;
- local details become global vocabulary.
Navigation cost is part of the architecture.
Colocation keeps private behavior near its owner
features/catalogue/
ProductCard.tsx
ProductCard.test.tsx
ProductCard.module.css
product-card-format.tsColocation shortens the path from behavior to styles to tests.
Move files outward only when they become shared or when the repository’s structure makes ownership clearer elsewhere.
Component boundaries and accessibility
An accessible pattern has a behavioral contract:
- roles and relationships;
- keyboard interaction;
- focus movement;
- names and descriptions;
- disabled and busy states.
Keep these rules with the component that owns the interaction, especially for dialogs, tabs, menus, and composite widgets.
Component boundaries and internationalization
Do not bury locale assumptions in generic components.
<Price amount={product.priceCents} currency={currency} locale={locale} />The component can format correctly while the feature decides which domain value and locale it is displaying.
Text, plural rules, direction, and date conventions are part of the public behavior.
Practical refactoring strategy
- Identify responsibilities.
- Identify shared state.
- Extract stable visual responsibilities.
- Keep coordination in the parent initially.
- Refine APIs.
- Move truly reusable primitives downward.
- Add context or injection only when explicit passing is genuinely burdensome.
Refactoring is a sequence of smaller decisions, not a single rewrite.
Practical stages 4–6: test the interaction
- Test arrow-key navigation, disabled tabs, and dynamic panels.
- Compare explicit props, context/provide-inject, and a headless API.
- Verify that selected tab and visible panel remain synchronized.
The keyboard behavior should be independently testable from the visual treatment.
Troubleshooting guide (Part 1)
| Symptom | Likely cause |
|---|---|
| Props are forwarded through many layers | Ownership or a real shared dependency is unclear |
| Every component has many booleans | Invalid combinations are not modeled |
| A child and parent fight over state | The controlled contract is ambiguous |
| Context appears everywhere | Dependencies are hidden instead of designed |
Completion checklist
- each extracted component has a coherent responsibility;
- public props and events express intent;
- state ownership is explicit;
- controlled and uncontrolled modes are not mixed accidentally;
- compound APIs have a real relationship to model;
- headless behavior is independent from styling;
- context or injection is used only where it clarifies composition;
- accessibility behavior belongs with its interaction owner;
- the final structure reflects feature or domain change patterns.
Misconceptions to leave behind (Part 1)
| Misconception | Better mental model |
|---|---|
| Every repeated markup needs a component | Responsibility and change patterns matter |
| Components exist mainly for reuse | Reasoning, isolation, and ownership matter too |
| Smaller components are always better | Navigation cost is part of quality |
| Each component owns all its state | The owner is the decision-making unit |
Misconceptions to leave behind (Part 2)
| Misconception | Better mental model |
|---|---|
| More props mean more flexibility | More public combinations mean more obligations |
| Context is better than prop drilling | Context trades repetition for visibility |
| Reusable means generic | Reuse should preserve meaningful vocabulary |
| A framework decides architecture | Frameworks provide mechanisms, not boundaries |