The Reusability Trap
Here's a real situation. You're building a SaaS app. You have two modals:
- A confirmation modal that appears before a user deletes their account.
- An onboarding modal that collects their team preferences on first login.
Both render a title, some body content, and a form with a submit button. A senior engineer notices the pattern and extracts a GenericModalWithForm:
// Looks clean. Is actually a time bomb.
function GenericModalWithForm({
title,
description,
fields,
submitLabel,
onSubmit,
onCancel,
variant, // 'destructive' | 'default'
isLoading,
errorMessage,
showCancelButton,
cancelLabel,
closeOnOverlayClick,
footerContent,
}) {
// ...
}Three months later, the design team wants:
- The delete modal to require the user to type "DELETE" to confirm.
- The onboarding modal to support a multi-step flow with a progress indicator.
Now you're either adding more props to an already-crowded component, or you're writing special-case logic inside it:
{variant === 'destructive' && (
<ConfirmationInput expectedValue="DELETE" onChange={setConfirmValue} />
)}
{isMultiStep && (
<ProgressIndicator steps={steps} currentStep={currentStep} />
)}Every new requirement breeds a new prop. Every new prop makes the component harder to understand. Eventually, calling <GenericModalWithForm> requires reading its entire implementation to know what combination of props produces what behavior.
Two simpler, explicit components would have been easier to maintain:
// DeleteAccountModal.jsx — owns its own confirmation logic
function DeleteAccountModal({ onConfirm, onCancel }) {
const [value, setValue] = useState('');
return (
<Modal title="Delete your account?" variant="destructive">
<p>This action cannot be undone. Type DELETE to confirm.</p>
<input value={value} onChange={e => setValue(e.target.value)} />
<Button disabled={value !== 'DELETE'} onClick={onConfirm}>
Delete account
</Button>
</Modal>
);
}
// OnboardingModal.jsx — owns its own step logic
function OnboardingModal({ onComplete }) {
const [step, setStep] = useState(0);
// ...multi-step logic lives here, affects nothing else
}More lines, yes. But each file is responsible for exactly one thing. Changes to the delete flow don't touch the onboarding flow. New engineers can read either file in isolation and understand it completely.
The lesson: visual similarity is not a good reason to share an abstraction. Two components that look alike today but evolve for different reasons will punish you for coupling them.
When Custom Hooks Go Wrong
Custom hooks are wonderful. They're also the easiest place to accidentally build a mini framework.
Say you have a pattern that appears in three places: fetch some data, handle loading and error states, transform the response. You extract a hook:
// First version — reasonable
function useUserData(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(data => setUser(transformUser(data)))
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
return { user, loading, error };
}This is fine. It's focused. It does one job. But over time, it grows:
// Six months later — not fine
function useUserData(userId) {
const navigate = useNavigate(); // ← now tied to routing
const { showToast } = useToastContext(); // ← now tied to UI feedback
const queryClient = useQueryClient(); // ← now tied to cache invalidation
useEffect(() => {
fetchUser(userId)
.then(data => {
const transformed = transformUser(data);
setUser(transformed);
queryClient.setQueryData(['user', userId], transformed); // ← side effect
})
.catch(err => {
setError(err);
showToast({ type: 'error', message: 'Failed to load user' }); // ← UI decision
if (err.status === 401) navigate('/login'); // ← navigation decision
});
}, [userId]);
return { user, loading, error };
}This hook now:
- Fetches and transforms data (data concern)
- Decides what toast to show (UI concern)
- Decides where to navigate on error (routing concern)
- Manages a query cache (infrastructure concern)
Try using this hook in a background sync context where you don't want a toast. Try using it in a component that handles auth errors differently. You can't — the decisions are baked in.
The fix isn't to stop extracting hooks. It's to extract around responsibilities:
// Data hook — only knows about data
function useUserQuery(userId) {
return useQuery(['user', userId], () => fetchUser(userId).then(transformUser));
}
// Component — knows about UI and routing
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useUserQuery(userId);
useEffect(() => {
if (error?.status === 401) navigate('/login');
}, [error]);
if (error) return <ErrorMessage onRetry={refetch} />;
if (isLoading) return <Skeleton />;
return <ProfileCard user={user} />;
}Now the hook is actually reusable. The component owns UI decisions that are appropriately its own.
DRY Is Not What You Think It Is
"Don't Repeat Yourself" is one of the most misquoted principles in software. Its original definition — from The Pragmatic Programmer — is about knowledge duplication, not code duplication. The actual quote is:
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
That's different from "never write the same line twice."
Here's an example. You have two form components. A signup form and a checkout form. Both validate an email field using the same regex. A developer extracts a shared validateEmail utility. Totally reasonable — that regex represents a single piece of domain knowledge and should live in one place.
But now both forms also happen to render a <div className="form-row"> wrapping a label and input. Should that be extracted too?
// Before extraction — both forms have this pattern
<div className="form-row">
<label htmlFor="email">Email</label>
<input id="email" type="email" />
</div>
// After extraction — a shared FormRow component
<FormRow label="Email" htmlFor="email">
<input id="email" type="email" />
</FormRow>Maybe. But what if the signup form later needs an inline helper text below the input? What if the checkout form needs to conditionally show a tooltip on the label? Now FormRow needs props for those things, and you're back to a bloated generic component.
Here's the question worth asking: do these two pieces of code exist for the same reason? If the markup is similar by coincidence — two separate teams solving separate problems that happen to look alike — keeping them separate might be the more honest choice. If the markup represents a genuine shared concept (a form field in your design system), extract it.
The difference isn't about line count. It's about whether the shared abstraction represents a real, stable concept or just a momentary structural resemblance.
The Rule of Three (and Why It Matters)
A useful heuristic: don't abstract until the third repetition.
The first time you write a piece of logic, you're solving a problem. The second time you write something similar, you might be tempted to extract it — but you don't yet have enough information about how it will vary. The third time, you have a pattern. You know what changes and what stays the same. That's when abstraction earns its place.
Even then, ask three questions before extracting:
- Are these changing for the same reason? If two data-fetching patterns look alike but serve different domains (user data vs. product catalog), they may evolve in incompatible directions. Shared abstractions across domain boundaries often cause more coupling than they prevent.
- Do they share business meaning, not just structure? Two components that both render a card with an image, a title, and a description might be completely unrelated concepts — a product card and a blog post card. The structural similarity is coincidental. An abstraction there hides the semantic difference without providing real value.
- Will I regret this when requirements change? This is hard to answer in advance, but thinking through a few hypothetical changes to each use case can reveal whether you're coupling things that will want to diverge.
If the answers are ambiguous, duplication is usually the safer bet. You can always extract later. Unentangling a premature abstraction is significantly harder.
Clever Composition and the Debugging Tax
React's compositional model enables some genuinely powerful patterns — compound components, render props, context-driven configuration. Used well, they're elegant. Used indiscriminately, they turn debugging into an archaeology project.
Here's a real pattern that starts elegant:
// A compound component pattern for a flexible Select
<Select value={value} onChange={setValue}>
<Select.Trigger>{value || 'Choose...'}</Select.Trigger>
<Select.Menu>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Menu>
</Select>This is reasonable. It's explicit and flexible, and you can see the structure. But now imagine a codebase where this pattern is applied to everything — tabs, accordions, modals, drawers, tooltips, all using context-driven compound APIs. A bug in a Tab.Panel means tracing through TabsProvider, TabsContext, Tab.Root, useTabsContext, and finally the panel itself before finding where the broken value originates.
Every layer of indirection has a cost. The cost is paid in debugging time, in onboarding time for new engineers, and in the cognitive overhead of understanding where a given value comes from.
The question to ask before reaching for a clever pattern is not "can I build this elegantly?" It's "what happens when this breaks at 2am and someone who didn't build it has to fix it?" Code is read far more than it is written. Optimize for the person reading it six months from now — who might be you.
What a Good Abstraction Actually Looks Like
Good abstractions share a few qualities that are worth holding onto as a checklist:
They represent a real concept in your domain, not just a structural convenience. A useAuth hook that wraps authentication state is a real concept. A useThreeThings hook that bundles three unrelated hooks because three components use all three is not.
They have a single, clear responsibility. If you can't describe what an abstraction does in one sentence without using "and," it's probably doing too much.
They reduce mental load, not just line count. The measure of a good abstraction is whether someone unfamiliar with it can use it correctly without reading its implementation. If using the abstraction requires understanding the abstraction, it hasn't paid for itself.
They make illegal states harder to represent. A well-designed TypeScript interface or component API that makes it impossible to pass a contradictory combination of props is genuinely valuable. A generic component with 12 optional props that all interact with each other in undocumented ways is the opposite.
The Senior Responsibility
The deeper problem with bad abstractions is one of scale. A junior engineer writes a messy component and it affects one file. A senior engineer introduces a bad pattern in a shared library and it propagates across the entire codebase. The more architectural authority you have, the more carefully you need to wield it.
The most valuable thing an experienced engineer can do is sometimes to not build the elegant system they're capable of building. To leave duplication in place when the pattern isn't settled yet. To delete a "flexible" abstraction that no longer serves the team and replace it with three boring, readable, specific components.
Restraint isn't a failure of creativity. It's the sign of someone who has seen what over-engineering costs.
The next time you feel the urge to extract something, try sitting with this question for a moment:
Am I removing complexity, or am I just moving it somewhere harder to see?
Invisible complexity is the most dangerous kind. The best abstractions illuminate. The worst ones obscure.