When a flexible component becomes difficult to use

A reusable component often starts with a clear job. A button accepts a label and an action; a dialog presents content and a way to close it. Over time, callers may ask for more options: a loading state, a compact layout, an icon, a status message, or a special footer. Each option seems reasonable on its own. Together, they can turn a component into a collection of conditional paths.

A useful warning sign is that callers must understand combinations of options rather than the component’s purpose. For example, a component with compact, showStatus, dismissible, and primaryAction flags may need rules for which combinations are valid. If changing one mode regularly affects another, the component is carrying more decisions than its name suggests.

The alternative is not to make every element unique. Keep genuinely shared behavior in small components, then assemble them where the surrounding context is known. A button can remain responsible for being a button while a save row decides whether it needs a status message.

Separate the reusable part from the arrangement

Start by listing what every instance must do. For an action button, that might be displaying a label, responding to activation, and using the appropriate native button element. Next, list what varies because of context: whether the button appears beside status text, inside a toolbar, or below a form. Those arrangements belong to the components that own those contexts.

Prefer an explicit component for a meaningful combination over a flag that changes a general component into something else. A SaveRow can compose an ActionButton and a Status; a Toolbar can compose several ActionButtons. This lets each caller read like a description of the interface rather than a set of configuration instructions.

Not every difference requires a new component. A label or callback is ordinary input, and a limited visual choice may be easier to maintain as an option. Split components when an option changes structure, behavior, accessibility requirements, or the responsibility for state. The goal is fewer surprising combinations, not the largest possible number of tiny functions.

A small example you can run in a browser

The following example uses plain JavaScript and the DOM. It needs no framework or build step: paste the code into the console of a modern browser on a page where you can add elements to the document body. It creates a reusable button and status element, then composes them into a save row.

function createActionButton(label, onClick) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.addEventListener("click", onClick); return button; } function createStatus(text) { const status = document.createElement("span"); status.setAttribute("role", "status"); status.textContent = text; return status; } function createSaveRow(onSave) { const row = document.createElement("div"); const status = createStatus("Not saved"); const button = createActionButton("Save", () => { onSave(); status.textContent = "Saved"; }); row.append(button, status); return row; } document.body.append(createSaveRow(() => console.log("Saved item")));

Here, createActionButton does not need a showStatus option. The save row owns the relationship between the action and its feedback. The native button supports keyboard activation, while the status element gives assistive technology a way to announce the updated message. In a real interface, the save row would also need to represent pending work and failure if saving can be asynchronous; it should not report success before the operation succeeds.

Keep state close to the behavior it describes

Composition works best when each piece has a clear owner for state. In the example, the save row updates its own status because it knows what the Save action means. The button does not need to know whether it is saving, deleting, or opening a panel. If several screens must share the same saving sequence, that sequence could move into a dedicated save component or a reusable function.

Avoid moving state upward solely to make every child configurable. A parent that passes a long list of flags to a general-purpose component may simply be relocating the complexity. Instead, decide which component can name the behavior clearly and which smaller pieces it needs. Keep information passing between those pieces explicit, such as a callback for an action and a value for displayed status.

Composition also makes exceptions easier to see. If one save flow needs confirmation and another does not, they can compose different steps without teaching every button about confirmation. If those flows later prove nearly identical, their shared behavior can be extracted with a clearer understanding of what is actually common.

Test the assembled experience

Small components still need tests at two levels. Check that a shared button renders its label and invokes its callback when activated. Then check the composed save row: activating Save should call the supplied action and update the status. For an asynchronous version, test pending, success, and failure separately, including whether repeated activation is allowed while work is in progress.

Review the result as an interface, not only as a set of functions. Can someone reach the control with a keyboard? Does its label explain the action? Is feedback available without relying only on color? Are related controls and messages placed together in a sensible reading order? Composition provides room to make these decisions locally, but it does not make them automatically.

When considering another variant, ask whether it describes a property of the shared component or a new arrangement with its own purpose. If it is an arrangement, compose existing pieces first. That habit keeps APIs easier to understand while leaving each interface free to express what it needs.

Back to category ↗