As developers, we’re taught early on to never repeat ourselves, it’s better to change code in one place, more predictable, easy to test and will lead to less bugs as we know where the code is and it’s just that one place. The DRY principle is a cornerstone of good engineering, but what happens when following it too strictly leads to a codebase that’s harder to maintain and understand? The instinct to abstract is strong, but sometimes, a little repetition is the smarter choice.
You’ve seen it a hundred times. Two components, two functions, or maybe even two sets of API calls that look almost identical. The logic is 90% the same, save for a few minor differences. Immediately, that little voice in your head whispers, “Don’t Repeat Yourself! Abstract it” The urge to create a single, elegant abstraction is overwhelming, you want that clean and nice code. It feels like the right thing to do. It feels like what a “good” developer would do.
I once worked on a project where we had two different components that showed user avatars. One was for a profile page, the other for a list of recent comments. They were slightly different in size and had different fallback text. To be “efficient,” we created a single Avatar
utility component with a dozen props to handle every possible state. What started as a simple component quickly became a Frankenstein’s monster of props and conditional logic. Any time a new use case came up, we’d have to add yet another prop, another if
statement. That initial act of “saving time” ended up costing us hours of debugging, cognitive load, and headaches down the road, not even mentioning the complexity of testing, as you need to keep the old tests, so you are not bringing any regressions to the party.
The DRY (Don’t Repeat Yourself) principle is one of the first things we learn as developers. Its intention is noble: to reduce redundancy, make code easier to maintain, and prevent logical inconsistencies. In an ideal world, we’d never write the same piece of code twice.
However, the real world of software development is messy. Requirements change. Features evolve. What looks similar today might diverge tomorrow. Prematurely applying the DRY principle can lead to code that is more brittle and harder to understand than the initial repetition it was meant to solve.
if/else
statements and optional parameters to handle every potential future case you can imagine. This complexity makes it hard to use, maintain and test.It might sound a bit crazy, but sometimes, a little repetition is a sign of a healthy codebase. Two small, simple components that are 90% the same are often easier to maintain than one overly complex, “smart” component.
Consider two similar React components. They both display a user profile card, but one has an “Edit” button and the other doesn’t. Instead of creating a UserProfileCard
component with an optional showEditButton
prop, you might be better off with two separate components: UserProfileCard
and EditableUserProfileCard
. Think of this as a compositional structure. At least until you’ve proven that the shared logic is truly stable. The initial repetition gives you space to iterate and see if a pattern truly emerges. Give the components freedom to evolve.
So, how do you know when to use DRY?
JSDoc
block or a long list of instructions, it’s probably too complicated.type
or interface
to ensure they both adhere to the same contract without needing a shared, overly-generalised component.Let’s look at a simple example. The buttons that shared almost the same class names. One is the primary button and the other is the secondary
function PrimaryButton() {
return <button className="rounded bg-blue-500 px-4 py-2 text-white">Click me</button>;
}
function SecondaryButton() {
return <button className="rounded bg-gray-500 px-4 py-2 text-white">Click me</button>;
}
Merging them too early leads to:
function Button({ variant }: { variant: 'primary' | 'secondary' }) {
return (
<button
className={`rounded px-4 py-2 text-white ${
variant === 'primary' ? 'bg-blue-500' : 'bg-gray-500'
}`}
>
Click me
</button>
);
}
Now adding a tertiary variant or adjusting spacing requires refactoring the abstraction. The “clever” solution often becomes more brittle than the original repetition. You are now locked into living with that abstraction and the mess begins from here. Adding more things will make it harder to maintain and difficult to notice any regression down the line. Again, let the components evolve freely, each has it’s own independence.
The Open-Closed Principle (OCP) states that software entities (like components) should be open for extension, but closed for modification. In the example of the Button
component, the initial abstraction violates this principle. When a new variant (like a “tertiary” button) is needed, you’re forced to modify the existing Button
component by adding a new if statement or a new variant to the type definition. This can introduce bugs to existing implementations and makes the component harder to test.
A design that adheres to OCP would allow you to create a new button variant without touching the original Button
component. This leads to a more stable and predictable codebase.
Instead of creating a single, complex component with conditional logic for all its variants, a better approach is to create a simple BaseButton
component that holds the proven, shared styles. The key here is to abstract only what’s truly reusable and stable.
Here’s how that would look:
// BaseButton.tsx
// This component is closed for modification and only contains shared, stable styles.
function BaseButton({ className, ...props }: BaseButtonProps) {
return <button className={`rounded px-4 py-2 text-white ${className}`} {...props} />;
}
export default BaseButton;
With the BaseButton
, you can now compose your primary and secondary buttons by extending its styling, without ever modifying the base component.
// PrimaryButton.tsx
// This component is a clear extension, not a modification of the base.
import BaseButton from './BaseButton';
function PrimaryButton() {
return <BaseButton className="bg-blue-500">Click me</BaseButton>;
}
// SecondaryButton.tsx
// This component is a clear extension, not a modification of the base.
import BaseButton from './BaseButton';
function SecondaryButton() {
return <BaseButton className="bg-gray-500">Click me</BaseButton>;
}
This approach is much more robust. When you need a new TertiaryButton
, you simply create a new component that extends the BaseButton
. You don’t have to touch the BaseButton
component or risk breaking the primary and secondary variants. This design is also easier to reason about and test, as each component has a single, clear responsibility. It’s a prime example of using composition over inheritance to achieve flexibility and maintainability.
Abstraction is powerful, but only when used deliberately. Encourage discussion in your team before merging similar code. A few minutes of discussion can save you from a major refactoring headache down the road. Let patterns emerge naturally, and rely on experience to identify when a DRY abstraction is genuinely valuable. Focus on readability, maintainability, and testability - your future self (and your team) will thank you.
Subscribe to get updates on new blog posts, useful frontend tips and ideas you can apply in your own work.