Keeping React components boring on purpose
Why predictable component boundaries often matter more than clever abstractions in everyday React work.
React makes it easy to create abstractions. That is useful, but it also creates a temptation to make components more impressive than the problem requires. In everyday product and website work, the best components are often boring: named clearly, scoped honestly, and easy to delete when the design changes.
Boring is not lazy. It means the component reveals its job without requiring a tour of the architecture. The props describe real choices, the data arrives in a useful shape, and the unusual states are visible in the file where someone expects to find them.
Start with a responsibility, not a rectangle
A component called Card can mean almost anything. A component called ProjectCard has a purpose, a likely data shape, and a context where it belongs.
Generic components are useful when the repetition is real. Buttons, inputs, badges, and layout primitives often deserve shared treatment because they express stable design-system rules. Not every repeated rectangle needs to become universal. Two sections can look similar today and diverge next week because they represent different content and interactions.
I begin by asking what the component owns. A project card may own the link, image treatment, project metadata, and technology tags. It should not also decide how projects are fetched, sorted, or featured on the homepage. A clear boundary makes both sides easier to change.
Naming by purpose prevents false abstraction. If two purpose-built components later reveal a genuinely stable shared layer, that layer can be extracted with evidence rather than optimism.
Let repetition teach the API
Abstracting the first example usually means designing props for imagined future cases. The component collects flags such as compact, dark, centered, showIcon, and isSpecial before any real second use exists.
I prefer to let a pattern repeat a little. The repetition shows which parts are stable, which values vary, and which similarities are only visual. After two or three uses, the useful API is usually smaller.
This does not mean copying indefinitely. It means delaying the cost of a shared contract until the contract can be based on actual callers. Duplication is visible and local. A premature abstraction can spread hidden coupling through the codebase.
When extraction does happen, I review every call site. A prop should represent a meaningful product or design choice, not a patch for one caller. If the variants cannot be named clearly, the shared component may not be ready.
Keep props close to the domain
A good prop API makes valid use easy. variant="primary" communicates a known design-system choice. A combination of blue, filled, largeText, and strongBorder lets callers assemble visual states that may never have been designed together.
Boolean props are useful for truly independent behaviour, but several booleans often create impossible combinations. A discriminated variant or separate purpose-built component can make the supported states explicit.
I avoid passing entire records when the component needs only a small view of them. A card that accepts a full database response becomes coupled to unrelated fields and backend changes. Passing a focused project summary type documents what the UI actually requires.
There is a balance. Destructuring twenty individual props can be noisier than accepting a stable domain object. The deciding question is whether the object is already the component’s meaningful concept or merely the largest object available at the call site.
Transform data at the boundary
I prefer presentational components to receive data in the shape they render. Routes, loaders, or utilities can parse content, derive URLs, format dates, and handle missing values. The component can focus on semantics and interaction.
This keeps infrastructure out of small UI elements. A project card should not know where MDX files live. An article card should not parse a publication date differently from the article header. One formatting utility and one content boundary make those decisions consistent.
It also improves testing. The component can render a small fixture instead of recreating the content system or mocking a network client. Boundary code can be tested separately for parsing, sorting, and validation.
Server and client boundaries deserve the same care. A component should not become client-side merely because a parent passed more than it needed or because one small interaction was placed too high in the tree. Keeping interactive islands focused reduces JavaScript and clarifies ownership.
Keep state near the interaction
State should live at the lowest level that can coordinate the behaviour. A mobile menu can own whether it is open. A form can own field and submission state. Moving every value into a global store does not make the application more organised if no distant component needs it.
Local state is easier to trace because the event, transition, and rendering are close together. When state must be shared, I identify the real owner rather than defaulting to the highest common ancestor in the entire application.
Derived values usually should not become separate state. If a filtered list can be calculated from the items and current query, storing all three creates synchronisation work. Memoisation can be added when measurement shows the calculation is expensive; it is not required to make the code look optimised.
Effects need a similarly narrow role. They are appropriate for synchronising with systems outside React. They should not become a general place to calculate values that could have been derived during rendering.
Do not hide layout too early
A reusable layout component can remove noise. It can also make a simple page harder to understand. A component with many slots, responsive props, render callbacks, and alignment flags may conceal the structure that normal markup would show directly.
I use layout primitives when they encode stable rules such as container width, section rhythm, or stack spacing. Page-specific composition can remain in the page. Reading the JSX should reveal the order and relationship of the content without jumping through several generic wrappers.
Composition is often cleaner than configuration. Passing children into a focused shell can be easier to understand than teaching a universal component every layout it might contain. The shell owns what is truly shared, while the caller keeps the page-specific structure visible.
Make states explicit
A component is not finished when the happy path renders. It may need loading, empty, error, disabled, focus, selected, long-content, or missing-image states.
I prefer explicit branches with useful names over deeply nested conditional expressions. A reader should be able to see what the user receives when data is unavailable. If the component cannot recover, the surrounding boundary should decide how to present the failure.
State coverage also tests the quality of the boundary. A focused component is easier to render with an empty collection or long title. A clever component with hidden assumptions tends to fail when content stops matching the original fixture.
Not every component needs every state. A static badge does not require a loading variant. The relevant states come from its job, not from a design-system checklist applied without context.
Treat accessibility as part of the API
A shared component can spread accessible behaviour or spread the same mistake everywhere. Buttons should render as buttons when they perform actions. Links should render as links when they navigate. A clickable div with a keyboard patch is a poor primitive to repeat.
Icon-only controls need an accessible name. Form components need a reliable relationship between label, input, description, and error. Focus styles should be built into the shared treatment rather than recreated by every caller.
I am cautious with components that accept arbitrary elements through an as prop. Polymorphism can be useful, but it can also allow combinations of styling and semantics that the component was not designed to support. A smaller set of explicit options is often easier to trust.
Optimise after measuring
React code can collect memo, useMemo, and useCallback before there is evidence of a rendering problem. These tools have valid uses, but they also add dependencies and make data flow harder to read.
I first keep state local, avoid unnecessary effects, pass focused data, and inspect actual rendering behaviour. If a measured interaction is slow, optimisation can target the expensive boundary. A stable callback is not automatically valuable if the child is inexpensive and not memoised.
The same applies to code splitting and lazy loading. Large, rarely used interactive features may benefit. Splitting every small component creates more boundaries without improving what the visitor feels.
Make deletion and change inexpensive
A useful component should be easy to remove when the product changes. That is a sign that its dependencies point in sensible directions. If deleting a marketing section requires changing global state, a generic renderer, several registries, and a theme adapter, the abstraction has made a local decision expensive.
I keep files close to their use until reuse is real, avoid index files that conceal where code comes from, and remove obsolete variants rather than keeping them for hypothetical callers. Version-control history can recover old code; the active API does not need to preserve every experiment.
Readable React code is designed for the next person opening the file during an ordinary change or an urgent fix. Simple props, visible structure, focused state, and honest names make that moment calmer.
A component system succeeds when it reduces the number of decisions required to build consistent interfaces. If every change becomes a negotiation with the architecture, the system is too clever. Boring components do their job, expose the choices that matter, and stay out of the way.