Don’t Build the Reusable Component Yet

This post was originally published on this site.

Everyone who ever worked in frontend knows what kind of advice they are going to receive: to make things reusable. Find similar screens, create an abstracted component. Now, enjoy the little pleasure of deleting extra code. However, sometimes there are cases when I would like to say the exact opposite of this, for a short period of time, at least. Not only does the creation of an abstracted component mean higher costs of coding but also higher costs of coordination.

Duplication can easily be removed, but a wrong abstraction can be expensive to undo.

The case: one component, three workflows

In old days, there were three workflows, where I had to perform an “item action,” which consisted of workflow approval (“Do you really need this item?”), workflow change (“Change item’s settings”), and workflow movement (“Move item from/to managed area”).

There was nothing unusual about the interface for these actions – item list, select, confirm, and show the results. Same tables, pop-ups, and toasts. It just seemed ridiculous to create three almost identical actions; so, I decided to create only one.

What I did not understand back then is that the similarities were only on the surface. I have not understood how each of those processes deals with partial failure, what are the requirements for each of them, and what information should be gathered for auditing purposes. Approval of an item action is an assertion about the future (“I will need this item”). Modification occurs instantly. Relocation requires another system to accept the request, and that system may reject it.

How it went wrong: flags, modes and a growing list of exceptions

The first iteration only featured one object that was important. After several weeks, it became more or less like this (an illustration, not the actual code):

type Item = { id: string; name: string };
type ItemActionProps = {
  item: Item;
  mode: "approve" | "change" | "move";
  variant?: "inline" | "dialog" | "bulk";
  showDetails?: boolean;
  requireReason?: boolean;
  onSpecialCase?: (item: Item) => boolean;
};
function ItemAction({ item, mode, variant, showDetails, requireReason, onSpecialCase }: ItemActionProps) {
  if (mode === "move" && onSpecialCase?.(item)) {
    return <SpecialCaseNotice />;   // only moves need this
  }
  const needsReason = mode === "change" || requireReason;
  // ...three submit paths, three error shapes, one component
}

All those were justified additions. The formula was that each additional screen came with a justification why this particular component could not be changed. Each additional requirement on the moves required a new flag. If that flag was cumbersome, the other alternative was a change of shared behavior, and then it had to be verified that approvals and changes still work.

Any change to the confirmation procedure could no longer be done by the owner of the workflow, but would require agreement from all three owners, the reviewer who knew all the modes, and test cases for all the combinations of mode and variant. An alteration in one area led to an argument about all the others.

Why it happens

Indeed, there are multiple advantages for applying the earlier mentioned approach to abstraction. Cleaner code is good code. The diff that allows eliminating two hundred lines of code deserves to be appreciated. The consistency seems to be another rational way of approaching the design.

In this case, all you have is the information in the current state. Many other similar cases show you that many cases have some common attributes. However, they cannot tell you about the differences between them, because the differences will be evident in the future: new policy, new audit finding, new integration that does not behave as you expected.

This means that the abstraction is based on current similarities, while any differences will have no other thing to do but to serve as flags.

The rule: wait for three, then check the reasons

However, until the pattern is repeated three times with the same reason for changing, wait. In the meantime, simply grab the code and let each screen evolve on its own.

This second half is equally important as the first half. Two codes share the same reason for change when a single decision made by a single individual changes both at the same time. Take a look at these:

// Same reason to change: one product decision about how dates look.
formatExpiry(item.expiresAt)   // approve, change and move screens
// Different reasons, despite identical-looking code:
await api.post("/approve", body); toast.success("Done");   // changes with approval policy
await api.post("/change", body);  toast.success("Done");   // changes with validation rules

Date formatter is fine to extract since changing formats affects all screens. Submit buttons appear duplicated but belong to different owners. Combining them binds two unrelated decisions together.

My test is, “In the case of change to this requirement, who would I consult, and would all copies be changed simultaneously?” In case your answers vary depending on copies, you deal with coincidences rather than duplications.

This is not a novel concept. “Rule of three” was attributed to Don Roberts in Refactoring by Martin Fowler. The very first time you repeat some code, you just do it; the second time you wince but still do it; the third time, you refactor. Sandi Metz expressed the same beautifully in The Wrong Abstraction where she said that duplications are much cheaper than the wrong abstraction. Kent C. Dodds’ AHA programming also suggests avoiding hasty abstractions.

Where reuse does pay off

This is not “never abstract”. Some stuff is worth sharing from Day One:

  • Primitives that stay stable. Buttons, text inputs, layout containers. Small and boring interfaces; very little business logic contained within them.
  • Design tokens. Colors, spacing, and type scale exists to be used across multiple screens at once. That is the whole point of them.
  • Accessibility behavior. Focus trapping in dialogs, keyboard navigation, labelling. Getting it right once is better than getting it wrong slightly in five places.
  • Anything driven by a standard. Dates and times, currency formatting, protocol clients. Shape is defined externally, so the design of your next screen is less likely to distort it.

I distinguish such abstractions with these three criteria. Is the shape determined somewhere else than by my speculations on the future? Is there a single reason why it should be changed? Is it a component or a workflow?

The best smell test is always to read the list of props. If they describe what should be rendered (children, size, tone), then the abstraction is probably fine. If they are telling you in what situation you are (mode, variant, isSpecial), you have most likely combined several similar things into an abstraction.

How to recover from a premature abstraction

Start by inlining the common module back into each call site. Copy the module’s code to each workflow, and remove branches that workflow never takes. This approach is retrograde, but now all copies are short and easy to understand, and all workflows can be changed without permission from anybody else.

Next, separate the code according to the workflow rather than layout. Once the three codes are stabilized, find out what is now common due to the same reasons such as the table or date formatters and extract just the small pieces of code. This is also suggested by Metz in her essay.

The rule, once more

Copy and paste until the same pattern shows up three times for the same reason; but uncover the pattern first if you have got primitives, tokens, accessibility, and standardization.

So, the question that I want to ask you is: what is that piece of code which people dance around, and what is that special flag which you employ for that particular caller?

————————

This article is licensed under CC BY-SA 4.0. Ideas credited to Don Roberts (via Martin Fowler’s Refactoring), Sandi Metz and Kent C. Dodds.

AI disclosure: I wrote this article myself and did not use generative AI.

Hot this week

Bonnie Tyler’s hometown pays tribute with new mural

Bonnie Tyler's hometown pays tribute with new muralByAimee ThomasBBC...

Henry Nowak’s killer has minimum term increased to 25 years by Court of Appeal

Judges ruled Vickrum Digwa's original term was unduly lenient and refused his bid to have it cut.

Cowboys legend Dez Bryant calls for new head coach while naming Oklahoma’s OC as main target

Dez Bryant wants Jason Witten, Oklahoma’s interim offensive coordinator,...

Harbinger raises $240M after expanding into defense

Electric truck startup Harbinger has raised a $240 million...

Topics

Bonnie Tyler’s hometown pays tribute with new mural

Bonnie Tyler's hometown pays tribute with new muralByAimee ThomasBBC...

Henry Nowak’s killer has minimum term increased to 25 years by Court of Appeal

Judges ruled Vickrum Digwa's original term was unduly lenient and refused his bid to have it cut.

Cowboys legend Dez Bryant calls for new head coach while naming Oklahoma’s OC as main target

Dez Bryant wants Jason Witten, Oklahoma’s interim offensive coordinator,...

Harbinger raises $240M after expanding into defense

Electric truck startup Harbinger has raised a $240 million...

Southport and Nottingham attack victims demand end to data breaches

Families described the unauthorised access of confidential medical records as an "abuse of power".

Fulham vs Newcastle United Prediction and Betting Tips | November 7th 2026

Unravelling Newcastle United face a long journey south to...
spot_img

Related Articles

Popular Categories

spot_imgspot_img