Ask a frontend developer to explain SOLID, and almost every answer looks like this:
class Shape {
area(): number { /* ... */ }
}
class Rectangle extends Shape { /* ... */ }
class Square extends Rectangle { /* the famous Liskov trap */ }
Everyone nods. Nobody in the room has written class Shape in years. Their codebase is function components, hooks, and a server/client boundary that didn't exist when SOLID was written.
So what's actually left of SOLID in React? Most articles answer by finding a React example for each letter: a hook for S, children for O, Context for D. That skips the real question, which is why each principle existed. SOLID has one goal: when something changes, limit what breaks. Classes were just the mechanism.
So I'll test each letter the way it was meant to be tested, with a change request. My conclusion up front: all five principles still hold. What doesn't hold is how they're usually explained. SRP gets read as "does one thing". Context gets sold as dependency inversion. And the favorite argument for narrow props — "a new object breaks memo" — was never ISP's reasoning to begin with.
Each letter covers the same three things: the primary sources, a change request with its fix, and an In plain words picture you should be able to retell to a friend.
What's inside. It's a long one, so here's the map:
- The component everyone has: one pricing card we'll rebuild letter by letter
- S: it was never "does one thing"
- O: Meyer's version died, Martin's didn't
- L: the contract of the element you wrap
- I: narrow props, for Martin's reason, not the one most articles give
- D: it's about who owns the interface
- The card, rebuilt: all five fixes in one place
- What survived, and what didn't: the summary table
- When to break it on purpose
- Closing
The component everyone has
A pricing card. Every product has one. Here's the version that grows in every codebase if nobody stops it:
'use client';
type ApiPlan = {
id: string;
title: string;
price: { amount: number; currency: string };
billing_period: 'month' | 'year';
is_popular: boolean;
};
export function PricingCard({ planId, variant }: { planId: string; variant: 'default' | 'discount' }) {
const [plan, setPlan] = useState<ApiPlan | null>(null);
const router = useRouter();
useEffect(() => {
fetch(`/api/plans/${planId}`).then((r) => r.json()).then(setPlan);
}, [planId]);
if (!plan) return <Skeleton />;
const amount = variant === 'discount' ? plan.price.amount * 0.8 : plan.price.amount;
const price = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: plan.price.currency,
}).format(amount);
return (
<div className={styles.card}>
{plan.is_popular && <span className={styles.badge}>Most popular</span>}
{variant === 'discount' && <span className={styles.badge}>-20%</span>}
<h3>{plan.title}</h3>
<p className={styles.price}>{price} / {plan.billing_period}</p>
<p className={styles.legal}>
Renews automatically at {price} every {plan.billing_period}. Cancel anytime.
</p>
<button
className={styles.cta}
onClick={() => {
analytics.track('plan_selected', { planId: plan.id, variant });
router.push(`/checkout?plan=${plan.id}`);
}}
>
Choose plan
</button>
</div>
);
}
It renders and it works — it even passed review. (The useEffect fetch has no cleanup, no error handling, and can race. That's fine for a "before" picture and not what this article is about.)
There's also one real bug in there. Hold that thought — it's the best argument for the first letter.
Now five different people are going to ask you to change this card. Each one gets a letter. Each fix builds on the previous one, so by the end the card is rebuilt piece by piece.
The shapes we'll be moving towards, so you don't have to hunt for them later:
type Period = 'month' | 'year';
// what the UI needs to show a plan — owned by the UI, not by the backend
type PlanView = {
id: string;
title: string;
amount: number;
currency: string;
period: Period;
isPopular: boolean;
};
// the Intl.NumberFormat call from above, extracted: formatPrice(9.99, 'USD') → '$9.99'
declare function formatPrice(amount: number, currency: string): string;
ApiPlan is what the backend sends. PlanView is what the UI wants. Keep the difference in mind — the last two letters are built on it.
S — Single Responsibility: it was never "does one thing"
What it actually says
Ask ten developers what SRP means and nine will say "a component should do one thing."
That's not SRP. "A function should do one thing" is a rule about functions, from Clean Code. SRP is about something else, and Robert Martin spent years correcting the misreading. In a 2014 blog post he put it bluntly:
"This principle is about people."
And in Clean Architecture (2017) he rewrote the definition itself:
"A module should be responsible to one, and only one, actor."
An actor is a group of people who request changes — someone who will come and ask you to change this code.
The change request
Go back to PricingCard and count the actors:
| Part of the component | Who asks to change it |
|---|---|
| Layout, badge, styles | Designer |
discount logic |
Product manager running an A/B test |
fetch + ApiPlan shape |
Backend team |
| Renewal text | Legal |
analytics.track(...) |
Analytics |
Five actors, all in one file.
And here's the bug from earlier. Look at the renewal line: Renews automatically at {price}. That price is the discounted one. When the product manager added the discount variant, they changed a variable the legal text also uses, and the legal text started promising a renewal at the discounted price. Nobody touched the disclaimer. It changed anyway, because two actors shared one variable.
It gets worse with people. In the same sprint, the designer asks for a new layout and legal asks for new disclaimer wording. Two developers, two tickets, one file. One of them resolves a merge conflict inside the other's JSX. Martin describes this case in Clean Architecture and calls it "Symptom 2: Merges".
That's the real SRP violation, and the length of the file has nothing to do with it.
The fix: split along actors, not along lines of code
// actor: backend — data and its shape
export async function getPlans(): Promise<PlanView[]> { /* ... */ }
// actor: legal — one file, one reviewer. gets the *renewal* price, not the display price
export function RenewalDisclaimer({ renewalPrice, period }: { renewalPrice: string; period: Period }) {
return <p className={styles.legal}>Renews automatically at {renewalPrice} every {period}. Cancel anytime.</p>;
}
// actor: analytics — a plain function, no hooks needed
export function trackPlanSelect(planId: string, variant: string) {
analytics.track('plan_selected', { planId, variant });
}
// actor: designer — markup only
export function PricingCard({ title, price, period, badge, footer, onSelect }: PricingCardProps) { /* ... */ }
(PricingCardProps is defined in the next section, where the card gets its slots. getPlans comes back in D under a new name.)
Now legal's change touches one file. The designer never opens it. And the disclaimer bug becomes hard to miss: the disclaimer asks for renewalPrice by name, so handing it the display price looks wrong in review. (Both are still strings. If you want the compiler to catch it too, give the renewal price its own branded type.)
What Next.js changed, and what it didn't
In the App Router, the split between Server and Client Components looks like SRP handed to you by the framework, but it's a different line: it's about bundles, not people.
The Next.js docs define the boundary technically. Server Components are for fetching data close to the source, keeping secrets off the client, and shipping less JavaScript. Client Components are for state, event handlers, and browser APIs. (Client Components still render to HTML on the server for the first load. The boundary is about which code gets shipped to and runs in the browser.)
That's a line about code and bundles. SRP is a line about people. They often land in the same place: data fetching on the server, click handlers on the client. But not always. getPlans() and <RenewalDisclaimer> can both run on the server (neither has state or handlers), and they answer to completely different people.
So 'use client' marks a bundle boundary. It says nothing about responsibility — you need both lines, and they're easy to mix up.
How people overdo it
The usual way to overdo SRP is fifteen files, five lines each: PricingCardTitle.tsx, PricingCardBadge.tsx, PricingCardPrice.tsx, and none of them has ever changed independently.
Before splitting anything, I'd rather count people than lines. The test I'd use is to open the file's history:
git log --format='%h %s' -- src/components/PricingCard.tsx
Read the ticket titles and look at who requested each change, not who committed it. One developer can ship tickets for design and legal in the same week, and that's still two actors. If over the last six months changes came from different requesters and they got in each other's way, split along that seam. If they all came from one place, or never collided, leave it alone. At that point a split doesn't reduce risk — it only adds files.
In a PR review
I'd skip "please split this component" and ask who the actor for each part is, and who will come and change it. If nobody can name two different people, the split isn't needed yet.
So SRP survived. It's just misread almost everywhere. In Clean Architecture, Martin defines a module, in the simplest case, as a source file, so the principle carries over to React unchanged. What doesn't survive is the "does one thing" version most of us learned.
In plain words: picture one shop shelf stocked by three suppliers: dairy, bakery, drinks. Dairy rearranges its part, and the bread gets pushed off the edge. Nobody touched the bread. They just shared a shelf.
- supplier = actor, the person who comes with changes
- shelf = module, the file
- bread falling off = the renewal-price bug: one change breaking someone else's part
- the fix = give each supplier their own shelf. Not one shelf per product, one per supplier.
O — Open/Closed: Meyer's version died, Martin's didn't
What it actually says
Bertrand Meyer formulated it in 1988, in Object-Oriented Software Construction: a module should be open for extension, closed for modification. His mechanism was inheritance. Extend the class, don't edit it. Martin later reframed it around abstractions and polymorphism: depend on an abstraction, and add new behavior by adding new implementations.
That fork matters for React. Idiomatic React avoids inheritance entirely. The old React docs put it plainly: "At Facebook, we use React in thousands of components, and we haven't found any use cases where we would recommend creating component inheritance hierarchies." So Meyer's mechanism is gone. Martin's, an abstraction you plug new things into, is what React calls composition.
The change request
Product wants a third variant for the next A/B test: a trial card with "7 days free" instead of a price.
In the original component, variant appears in three places: the amount calculation, the badge, the analytics payload. A third variant means three more branches inside a component that already works and already has tests. Every new experiment edits the same file. Every edit risks the variants that are already live — that's what "not closed for modification" looks like in practice.
The fix: slots, and one place that picks
The card stops knowing about variants at all. It exposes slots: props that accept any JSX (ReactNode). Same idea as children, just named:
type PricingCardProps = {
title: string;
price: ReactNode;
period?: Period; // optional since TrialCard — see below
badge?: ReactNode;
footer?: ReactNode;
onSelect: () => void;
};
Each variant is its own small component that fills the slots:
type CardVariantProps = { plan: PlanView; onSelect: () => void };
function DiscountCard({ plan, onSelect }: CardVariantProps) {
return (
<PricingCard
title={plan.title}
price={<StrikePrice was={plan.amount} now={plan.amount * 0.8} currency={plan.currency} />}
period={plan.period}
badge={<>{plan.isPopular && <Badge>Most popular</Badge>}<Badge>-20%</Badge></>}
footer={<RenewalDisclaimer renewalPrice={formatPrice(plan.amount, plan.currency)} period={plan.period} />}
onSelect={onSelect}
/>
);
}
Look at the footer: the disclaimer gets the full renewal price while the card shows the discounted one, which is the fix from S applied. (plan.amount * 0.8 keeps the example short. Real money code works in integer cents.)
DefaultCard and TrialCard follow the same pattern. TrialCard puts "7 days free" into the price slot, and here I hit the first real limit: the card still appends "/ month" after it. Hiding that means opening PricingCard once to make period optional (that's why PricingCardProps above has period?:). It's the first change of its kind, and that's Martin's moment to act: you close the card against this kind of change once, instead of patching around it in every variant. And it's a limit in the card's structure, not another variant, so it doesn't wait for the rule of three below.
And one place decides which card to render:
type Variant = 'default' | 'discount' | 'trial';
// DefaultCard and TrialCard: same shape as DiscountCard, different slots
declare const DefaultCard: ComponentType<CardVariantProps>;
declare const TrialCard: ComponentType<CardVariantProps>;
const cards = {
default: DefaultCard,
discount: DiscountCard,
trial: TrialCard,
} satisfies Record<Variant, ComponentType<CardVariantProps>>;
const Card = cards[variant]; // rendered in PricingGrid — see the rebuilt card after D
After that one edit, adding a variant means adding a file, one line to the map and one word to the union. The tested PricingCard isn't opened again. Closure is never total — something always changes — but you get to choose where. Martin calls this strategic closure: you decide which kinds of change your code is closed against. Here it's closed against "new variant", and the only thing that moves is one list.
About satisfies: Record<Variant, ...> means "an object with exactly these keys". Add 'annual' to the union and forget the card, and the build fails. A plain type annotation would catch that too. satisfies just keeps TypeScript's precise knowledge of which component sits under each key.
How people overdo it
Slots, render props, and a variant registry for a component that has had two variants for three years and will never have a third.
So when do you build the abstraction? Martin's own heuristic, from the OCP chapter of Agile Software Development (2002), is early: write the code as if it won't change, and when the first change of a kind arrives, add the abstraction that protects you from the next one. The rule of three (Don Roberts, via Fowler's Refactoring) is more patient: the third time, you refactor. For UI variants I lean towards three, because an inline if is cheap to write and cheap to undo, and a wrong abstraction isn't. Martin said the important part better than I can: "Resisting premature abstraction is as important as abstraction itself."
In a PR review
The trigger for me is someone adding another if (variant === ...) to the same component. That's the moment to talk about what the next variant would cost, and whether it could be a new file instead of a new branch. If the honest answer is "one more if, and there won't be a next one", let it live.
Where that leaves O: Meyer's inheritance is gone, and Martin's version is everywhere in React. We just call it composition.
In plain words: think of a Lego baseplate. It has studs, and you snap new bricks onto them. You never saw the baseplate apart to add a tower.
- baseplate =
PricingCard, tested and left alone- studs = slots (
price,badge,footer), the places where extension is allowed- a new brick = a new variant file (
TrialCard)- the catalog you pick the set from = the
cardsmap- "open for extension, closed for modification" = you can always add bricks, you never cut the plate.
L — Liskov Substitution: the contract of the element you wrap
What it actually says
Barbara Liskov, OOPSLA 1987. She proposed a substitution property as the definition of a subtype: if objects of type S can stand in for objects of type T in any program written against T without changing its behavior, then S is a subtype of T. In 1994, she and Jeannette Wing made it precise:
"Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T."
In working terms, that became a contract: a subtype must not demand more than the original (stronger preconditions) and must not promise less (weaker postconditions). Keep those two phrases in mind. Every bug below is one or the other.
This is also why the Square extends Rectangle answer from the top is a trap. It teaches Liskov as a geometry puzzle you'll never meet again, while in React you meet it every time you wrap a native element.
There's no inheritance to violate in React. But wrapping a native element plays the same role. The moment your team decides "use our <Button> everywhere a <button> was", you're making a substitution claim. Every line of code that was written for a native button now runs against your wrapper. Of the five letters, this is the one I'd check first in any design-system PR.
The change request
The design system team ships a shared <Button>. The ticket: "replace native buttons with the DS component across checkout." That ticket is the substitution.
Here's a <Button> that looks reasonable:
// ❌ looks like a button, doesn't behave like one
type ButtonProps = {
children: ReactNode;
onClick?: () => void;
loading?: boolean;
};
export function Button({ children, onClick, loading }: ButtonProps) {
return (
<button type="button" className={styles.button} onClick={loading ? undefined : onClick}>
{loading ? <Spinner /> : children}
</button>
);
}
Swap it into the checkout form and count what breaks:
-
The form stops submitting. A native
<button>inside a<form>defaults totype="submit". The wrapper hardcodestype="button". Clicking it no longer submits, and in a form with more than one field neither does Enter. (Promises less.) -
aria-labelis silently dropped. The icon-only close button is now unlabeled for screen readers. TypeScript won't even warn you — it doesn't report unknown attributes with a hyphen in their name, likearia-*. -
disableddoesn't exist. Somebody passes it anyway, TypeScript complains, somebody adds// @ts-ignore. (Demands more: "only use the props I allow.") -
refdoesn't reach the DOM. Focus-on-error logic that calledbuttonRef.current.focus()now calls it onnull. -
loadingreplaces the text with a spinner. The button loses its accessible name, and a screen reader now announces just "button."
TypeScript catches two of these, disabled and ref. If someone silences it, the ref one crashes later as Cannot read properties of null somewhere deep in focus logic. The other three ship without a sound. Each of them is a program written against <button> that behaves differently against <Button>. A textbook Liskov violation, no class needed.
The fix: accept the whole contract, add only what's yours
// ✅ a button that is a button
type ButtonProps = ComponentProps<'button'> & { loading?: boolean };
export function Button({ loading = false, onClick, className, children, ...rest }: ButtonProps) {
return (
<button
{...rest}
className={clsx(styles.button, className)}
aria-disabled={loading || rest['aria-disabled']}
onClick={(event) => {
if (loading) {
event.preventDefault(); // also cancels the form submit this click would trigger
return;
}
onClick?.(event);
}}
>
{/* opacity: 0 — keeps the width and the accessible name */}
<span className={clsx(loading && styles.invisible)}>{children}</span>
{loading && <Spinner className={styles.overlay} aria-hidden="true" />}
</button>
);
}
What each part does:
-
ComponentProps<'button'>is React's built-in type for every prop a native<button>accepts. Using it as the base means your wrapper starts with the full native contract. -
...restpasses through everything you didn't consciously change:type,aria-*,form,name,data-*, and in React 19reftoo. Since React 19, function components receiverefas a regular prop. For new components you no longer needforwardRef, and the React team has said it will be deprecated in a future version. -
aria-disabledinstead ofdisabledwhile loading. Adisabledbutton drops focus: if the user just pressed it, focus falls back to the page.aria-disabledkeeps the button focusable and tells assistive tech it's unavailable, and the click handler does the actual blocking.|| rest['aria-disabled']keeps a value the caller passed. Without it the wrapper would silently overwrite it, which is the same kind of break this whole section is about. Adrian Roselli has a good write-up on the focus problem: Don't Disable Form Controls. -
The label stays, the spinner overlays it. The button keeps its name. To announce loading, use one live region for the whole app (a small
useAnnounce()hook, say), not a live region per button. A pricing page with twenty buttons doesn't need twenty.
A caller can still pass native disabled. That's allowed: it's part of the native contract, with the native trade-off. And the wrapper still renders one <button> node. That matters more than it looks. A wrapper that returns a Fragment with an extra element next to the button breaks selectors like :only-child and > * + *, which is another quiet substitution break.
One more contract decision worth making on purpose: the default type. Many design systems deliberately default to type="button", because accidental form submits are a common bug. Be honest about what that is. It's a deliberate substitution break, since every existing <button> in a form relied on the native default. It's often a good trade. Own it: type stays overridable, and the migration codemod adds type="submit" wherever the native default was relied on. What's never OK is hardcoding it so the caller can't get the native behavior back.
In the card, the CTA now becomes <Button onClick={onSelect}>Choose plan</Button>.
Rolling it out without a big-bang PR
Replacing every button in one PR is how you ship all five bugs above at once. The incremental path: ship the new <Button> next to the old usages, run a codemod for the mechanical swaps, migrate area by area, and add a lint rule that bans raw <button> in new code. It's the same trick as banning a CSS property once a mixin covers it, which I wrote about in CSS in a Team. The old buttons shrink every sprint, and the new ones can't regress.
How people overdo it
A polymorphic as prop on every single component: <Button as="a">, <Text as="label">, <Card as="section">. The typings to make this safe are notoriously hard to read, and most components never get used as anything else.
Make the wrapper honor one native contract, fully. Add polymorphism only where you've actually needed it twice.
In a PR review
For any wrapper around a native element: "If I replace the native element with this one, what stops working?" Try type, aria-label, ref, and keyboard in a form. Four checks, two minutes.
L is the letter that changed least. The subtype is your wrapper, the base type is the native element, and the rule is the one Liskov wrote down in 1987. The contract framing from 1994 is the checklist above.
In plain words: you buy a cable labeled USB-C. It fits the port and charges your phone. Then you plug it into your laptop to copy photos off the phone, and nothing. The cable only does power, and every USB-C cable is supposed to carry data too. It fits, but it isn't the thing the label promised.
- the USB-C standard = the native
<button>contract: forms, keyboard,ref, screen readers- the cable = your wrapper
- "charges but no data" = a wrapper that clicks but doesn't submit the form, or drops
aria-label- the label = the name
Button, a promise to everyone who plugs it in- Liskov = if you put the label on, you deliver everything the label means.
I — Interface Segregation: narrow props, for Martin's reason, not the one most articles give
What it actually says
The origin story is worth knowing. Martin came up with ISP while consulting for Xerox on a printer system (and published it in 1996, as "The Interface Segregation Principle" in C++ Report). One fat Job class served printing, stapling, and faxing. Every change to it forced a rebuild of every client, even the ones that never staple anything. The fix was to split it into narrow interfaces, so each client depends only on what it actually uses.
Don't depend on things you don't use. In React, the things you depend on are your props.
The change request
The backend team ships v2 of the API. The only change: the billing period moves into a nested object.
// v2: billing_period → billing.period
type ApiPlanV2 = Omit<ApiPlan, 'billing_period'> & { billing: { period: Period } };
By now the card isn't the only consumer of plans. There's a comparison table, a plan summary in checkout, an upsell banner. In the original codebase, eight components across those screens take the whole plan:
<PlanTitle plan={plan} />
<PlanPrice plan={plan} />
<PlanPeriodLabel plan={plan} />
<PlanComparisonRow plan={plan} />
{/* ...four more */}
TypeScript will happily list every file that reads billing_period, so finding them is easy. The harder part is what each of those components is. Its contract says "give me the entire backend object." It can't be reused anywhere the backend object isn't. And even the ones that only read title drag that whole object into every test fixture and every story, which now break on a field the component never shows.
The fix: take what you render, map in one place
Two moves, and they do different jobs.
First, one mapper at the edge turns the API shape into PlanView:
export function toPlanView(api: ApiPlanV2): PlanView {
return {
id: api.id,
title: api.title,
amount: api.price.amount,
currency: api.price.currency,
period: api.billing.period, // the rename lands here. only here.
isPopular: api.is_popular,
};
}
This is what absorbs the rename: the backend changes a field, and one line changes. (In bigger systems this layer has a name, an anti-corruption layer: the place where someone else's model gets translated into yours.)
Second, components take what they render:
// ✅ the component says exactly what it needs
function PlanPrice({ amount, currency, period }: { amount: number; currency: string; period: Period }) {
/* ... */
}
function PlanTitle({ title }: { title: string }) {
/* ... */
}
This is the ISP part. PlanPrice no longer depends on the shape of a plan at all, the backend's or yours. It works for a plan, a gift card, an upsell banner. TypeScript also offers Pick<ApiPlanV2, 'title'> to narrow the type, but that still ties the component to the backend's field names. Plain props cut the tie completely.
The argument everyone uses, and why it's weaker in 2026
Almost every article on this gives the performance reason: pass the whole object and you break memoization. I don't find it convincing on its own, because it bundles two different claims, and only one of them belongs to ISP.
The first claim is that a new object on every render breaks memo. True, as far as it goes. The React docs say memo compares every prop with Object.is. That's a reference check: two objects with identical content are still "different" if they were created separately. But that's reference stability — it has nothing to do with ISP or with how many fields you pass. It's also the claim that's going away. Since React Compiler 1.0 shipped in October 2025, memoization is increasingly done for you: the compiler is a build step that adds the memo/useMemo equivalents automatically. It's opt-in, and the docs keep memo and useMemo as escape hatches, so "you never need them" would be wrong. But in projects that run it, this claim mostly stops applying.
The second claim is that a change to a field you don't render still re-renders you. Flip is_popular in the cache, and plan becomes a new object. A PlanTitle that takes the whole plan re-runs, even though its title didn't change. The compiler shrinks this one but can't remove it. The prop (the plan reference) really is new, so PlanTitle still gets called. Inside, the compiler caches the output on plan.title, hands back the previous JSX, and React skips the subtree. What's left is one function call and one comparison, which in practice is close to free. And this one is ISP. It's the runtime cousin of Martin's Xerox problem, where a change to Job forced a rebuild of clients that never used the changed part. Real, but usually cheap.
The part of Martin's argument that doesn't get cheaper is coupling. And the place you feel it most is tests.
// ❌ to test a title, construct an entire backend plan
render(
<PlanTitle
plan={{
id: 'p1',
title: 'Pro',
price: { amount: 9.99, currency: 'USD' },
billing: { period: 'month' },
is_popular: false,
}}
/>
);
// ✅ test what the component shows, nothing else
render(<PlanTitle title="Pro" />);
The first fixture has already been rewritten once, the day the backend moved billing_period, a field PlanTitle never renders. The second one breaks only when the title changes, and that difference is the whole of ISP.
How people overdo it
Twelve primitive props on a component that is genuinely about the whole object. A <PlanComparisonTable plans={plans} /> that shows every field of every plan should take the plans. Splitting it into titles, amounts, currencies, periods arrays doesn't reduce coupling. It just hides it in parallel arrays that must stay in sync.
Narrow props for components that show a part of the data. The whole object (your PlanView, not the backend's) for components that are about the object.
In a PR review
When a component takes a whole object: "Which fields does it actually render? Could it be reused if the object had a different shape?" If it renders two fields out of fifteen, narrow it.
What didn't survive here is the argument most articles make — that a new object every render breaks memo. It was never ISP's; it's reference stability, and the compiler is taking it away. Narrow props themselves are as right as ever, for the reason Martin gave at Xerox: a change to something you don't use shouldn't cost you. Back then the cost was a rebuild. Now it's a broken fixture, a component you can't reuse, and a re-run that's usually cheap.
In plain words: a courier needs your address, not your life story. Hand them your whole biography, and every time any detail changes — new job, new phone — the delivery has to be re-checked. Hand them only the address, and only a move affects them.
- courier = a component like
PlanTitle- the address = the props it actually renders (
title)- the whole biography = the entire backend object (
ApiPlanV2)- a new phone number = a backend change to a field the component never shows. Harmless for the address, a re-check for the biography.
- the post office that writes your address on the label once =
toPlanView, one place that turns the biography into what couriers need.
D — Dependency Inversion: it's about who owns the interface
What it actually says
Martin's original formulation, from 1996:
"High level modules should not depend upon low level modules. Both should depend upon abstractions.
Abstractions should not depend upon details. Details should depend upon abstractions."
And here's the trap almost every React article on this falls into: Dependency Injection is not Dependency Inversion.
- Dependency Injection (DI) is a mechanism: a way to hand a dependency to code instead of letting it import one. Props, Context, a hook. Martin Fowler named the pattern in 2004.
- Dependency Inversion (DIP, the Dependency Inversion Principle) is a principle about the direction of the dependency: who defines the contract that everyone else conforms to.
You can inject a dependency without inverting anything. It happens all the time.
This is the hardest letter, so here's the picture first and the code after.
In plain words: your laptop doesn't care which power plant the electricity comes from. It only cares that power arrives through one familiar socket, in one familiar form.
In real life it works the other way round: the grid decides what the socket looks like, and laptop makers adapt. DIP flips this. The laptop decides what the socket looks like, and every power plant builds a converter to fit it.
- what arrives, the form of power the laptop runs on =
PlanView, the shape of the data- the socket it arrives through =
PlansSource, the one way data gets in- power plants = backends. A converter between a plant and the socket = an adapter (
serverPlans,clientPlans)- DI = someone hands you an extension cord. You still plug into whatever they brought.
- DIP = the flip above. Switch plants, and the laptop never knows.
DI without DIP
// ❌ injected, yes. inverted, no.
const ApiContext = createContext<AxiosInstance>(axios);
function PricingSection() {
const api = useContext(ApiContext);
const [plans, setPlans] = useState<ApiPlanV2[]>([]);
useEffect(() => {
api.get<ApiPlanV2[]>('/v2/plans').then((r) => setPlans(r.data));
}, [api]);
/* ... */
}
The dependency is injected, and you can pass a fake in tests. But look at what the fake has to be: something shaped like axios, that responds to /v2/plans with a body shaped like ApiPlanV2[].
We didn't get rid of axios, though. We only changed where it comes from: an import became a useContext. The component still knows the HTTP client, the endpoint, and the backend's data shape. And we can't get rid of the data at all, because the card needs a price and a title. We were always going to depend on some data; the real question is whose shape it has. Here it's the backend's, so the arrow still points from the UI down to the infrastructure. Nothing was inverted.
The fix: the component owns the contract
before: PricingSection ──► axios, /v2/plans, ApiPlanV2
after: PricingSection ──► PlanView, PlansSource ◄── serverPlans / clientPlans (adapters)
The high-level code declares what it needs, in its terms. PlanView is already ours. Add the one thing the UI needs from a data source:
// owned by the UI layer
export type PlansSource = { key: string; list(): Promise<PlanView[]> };
The low-level code conforms to it. And this is where Next.js shows why it matters, because you need two adapters for the same contract:
// lib/plans/server.ts
import 'server-only'; // importing this file from a Client Component fails the build
// talks to the upstream service directly: on the server, a relative URL like '/api/...' doesn't resolve
export const serverPlans: PlansSource = {
key: 'server',
async list() {
const res = await fetch(`${process.env.PLANS_SERVICE_URL}/v2/plans`);
if (!res.ok) throw new Error(`plans: ${res.status}`);
const data: ApiPlanV2[] = await res.json();
return data.map(toPlanView);
},
};
// lib/plans/client.ts — for Client Components, goes through your own route handler
export const clientPlans: PlansSource = {
key: 'client',
async list() {
const res = await fetch('/api/plans');
if (!res.ok) throw new Error(`plans: ${res.status}`);
const data: ApiPlanV2[] = await res.json();
return data.map(toPlanView);
},
};
Why not just call your own /api/plans route from the server too? The Next.js docs are explicit: fetch data in Server Components directly from its source, not via Route Handlers. It's an extra HTTP round trip, and for pages prerendered at build time it fails the build. The server-only import guards the other direction. The server adapter reads an env variable that doesn't exist in the browser, so it must never end up in a client bundle.
Remember getPlans() from S, the backend actor's piece? This is what it became: the same job, now as serverPlans.list(), a method behind a contract instead of a free-standing function.
PlanView and PlansSource belong to the UI and both adapters depend on them — that's the inversion. And toPlanView from the previous section shows up again, because ISP and DIP meet at the same seam.
In the App Router you often don't need Context at all. The page picks the adapter and passes plain data down as props:
export default async function PricingPage() {
const plans = await serverPlans.list();
return <PricingGrid plans={plans} />;
}
PricingGrid has no idea HTTP exists. And the page is the one place that's allowed to know which adapter is real. It's the edge where concrete details are picked and wired in, so nothing below it has to know.
A Client Component that loads plans itself gets the contract, not the adapter:
function usePlans(source: PlansSource) {
return useQuery({ queryKey: ['plans', source.key], queryFn: () => source.list() });
}
(TanStack Query here. It needs a QueryClientProvider above in the tree, or it throws at runtime. Any data hook works the same way. source.key is in the query key so two sources never share a cache entry. You could put the whole source there too, but query keys are hashed as JSON and functions drop out, so the only thing that would survive is key anyway. Passing source.key just says that out loud.) Production passes clientPlans. A test passes a fake. The hook never finds out which.
For the curious: the names for this. If you've heard of hexagonal architecture, this is its core idea, ports and adapters (Alistair Cockburn).
PlansSourceis a port,serverPlansandclientPlansare adapters. One caveat for purists: in Cockburn's original the UI is itself an adapter outside the hexagon. In a frontend with no separate domain layer, the UI code is the application, so it sits inside. And the page that picks the real adapter has two names. Martin calls it the Main component in Clean Architecture, and the DI literature calls it the composition root.
What the next change requests cost
"Plans now come from a different service." One adapter changes. No component is opened.
"We need tests without a network." Two honest options:
- For
PricingGrid, which takes plainPlanView[], you don't need anything. Pass fixtures. For components that callusePlans(source), give them a fakePlansSourceand test them alone. - Mock at the network level with MSW (Mock Service Worker, a library that intercepts real
fetchrequests and answers them with test data) and test the adapter and the component together.
Both are fine. They answer different questions: does the UI render plans correctly vs does the whole chain work.
One trap with fakes, and it's the previous letter coming back. The fake is a substitute, so it has to honor the real contract. The real adapters throw when a request fails. If your fake returns [] instead, your tests pass against a behavior production never has. Liskov applies to test doubles too.
How people overdo it
D gets overdone by bringing a DI container (InversifyJS, decorators, a service registry) into a React app, or by adding a Context provider for every dependency until layout.tsx is nested twelve deep.
An import is a dependency too, and usually a perfectly good one. In most codebases I'd start with a plain import and invert only where the direction actually hurts: where a detail changes often, or where you need to swap it in tests. If there's one data source and it's never going to change, import { getPlans } is fine.
In a PR review
When you see a new Context or a new injected client: "Does this component still talk in the library's terms and the backend's field names?" If yes, the injection didn't buy the inversion.
The idea that Context equals dependency inversion is what died here. D itself came through intact: an abstraction owned by the high-level code, with concrete details picked at one edge, is Martin's 1996 mechanism. In React the abstraction is a type the UI owns, and the page is where the real adapter gets chosen.
In plain words, again: go back to the laptop and the socket above. Now you know what each part is in code. Try retelling it with the code names.
The card, rebuilt
Here's where the five change requests left the card from the top. Same features, minus the bug, and every piece now has one owner.
// app/pricing/page.tsx — the composition root: picks the real adapter and the variant
// reads the user's A/B assignment (cookie or flag) on the server
declare function getPricingVariant(): Promise<Variant>;
export default async function PricingPage() {
const [plans, variant] = await Promise.all([serverPlans.list(), getPricingVariant()]);
return <PricingGrid plans={plans} variant={variant} />;
}
// PricingGrid.tsx — wires the pieces together
'use client';
export function PricingGrid({ plans, variant }: { plans: PlanView[]; variant: Variant }) {
const router = useRouter();
const Card = cards[variant];
return (
<div className={styles.grid}>
{plans.map((plan) => (
<Card
key={plan.id}
plan={plan}
onSelect={() => {
trackPlanSelect(plan.id, variant);
router.push(`/checkout?plan=${plan.id}`);
}}
/>
))}
</div>
);
}
// PricingCard.tsx — markup only
export function PricingCard({ title, price, period, badge, footer, onSelect }: PricingCardProps) {
return (
<div className={styles.card}>
{badge}
<h3>{title}</h3>
<p className={styles.price}>{price}{period && <> / {period}</>}</p>
{footer}
<Button className={styles.cta} onClick={onSelect}>Choose plan</Button>
</div>
);
}
Who opens which file now:
| Change request from | Opens |
|---|---|
| Designer | PricingCard.tsx |
| Product (new A/B variant) | a new variant file + one line in the cards map + one word in Variant
|
| Legal | RenewalDisclaimer.tsx |
| Analytics | trackPlanSelect |
| Backend |
toPlanView and the adapters |
| Design system team | Button.tsx |
Each of the six requesters now opens a different file, and nobody has to touch someone else's.
One thing I glossed over: everything PricingGrid imports ships to the browser — RenewalDisclaimer included, because it lives inside the variant cards. To keep it server-only, the page would render the footers itself and pass them down as slots. For one paragraph of text I don't think it's worth it. That's the bundle line and the responsibility line from S disagreeing, on purpose.
What survived, and what didn't
All five principles survived React. In every case, what died is a version of the story: an old mechanism or a popular explanation. The verdict column says which kind. "Misread" or "confused with" means the usual explanation is wrong. "Martin's version" means one of two historical mechanisms died. "Popular argument didn't" means the principle holds, but the argument most articles give for it was never the principle's own.
| Letter | Original (who, when) | In React / Next.js | What didn't survive | How it's overdone | Verdict |
|---|---|---|---|---|---|
| S | Martin; "one actor" in Clean Architecture, 2017 | Split files and functions along the people who request changes; 'use client' is a separate, technical line |
"SRP = does one thing" | 15 files × 5 lines | Survived, misread |
| O | Meyer, 1988 (inheritance); Martin (abstractions) | Composition: slots (ReactNode props), a variant map |
Meyer's inheritance mechanism | Abstraction before the change it protects against | Survived, Martin's version |
| L | Liskov, 1987; Liskov & Wing, 1994 | A wrapper honors the full contract of the native element; in React 19, ref is just a prop |
Square extends Rectangle as the whole story |
Polymorphic as everywhere |
Survived intact |
| I | Martin, Xerox; published 1996 | Props = what you render; API shape mapped in one place | "A new object breaks memo": reference stability, not ISP, fading with React Compiler |
12 primitives for an object-centric component | Survived, popular argument didn't |
| D | Martin, 1996 | The UI owns the data contract; server and client adapters conform; data arrives as props | "Context = dependency inversion" | DI containers, a provider per dependency | Survived, confused with DI |
The goal behind all five stayed the same: a change requested by one person shouldn't break the work of another.
When to break it on purpose
Sometimes the right call is to ship the violation. Friday release, the A/B test has to start Monday, and the clean split would touch three files nobody has time to re-test. I'd add one more branch.
How to tell a good shortcut from a bad one: how expensive is it to undo? One more if inside a component is cheap. You can pull it out in an hour when the next variant arrives. A shortcut that spreads, like a shared Context everyone starts reading or a backend field threaded through ten components, gets more expensive with every file that touches it. Take the first kind freely. Don't take the second under deadline pressure — that's when it spreads fastest.
Martin Fowler's Technical Debt Quadrant has a corner for this: deliberate and prudent debt. You know you're taking it, and you've judged that shipping now is worth the cost of paying it back. What keeps it in that corner is naming it. Leave a comment that says which seam you skipped ("legal copy and pricing share this component; split along that line when either changes again") and a ticket that points to it. Then actually look at it the next time a change request lands in that file. That's when the debt either proves harmless or shows you where to cut.
Closing
For me SOLID works less as a checklist and more as a question to ask before you change a component:
Who is going to come and change this code next, and what will break for everyone else when they do?
If I had to drop the rest of SOLID and keep one thing, it would be this question. Answer it honestly and you'll land on most of the five letters without naming any of them. SOLID survived React just fine; what we should retire is the class Shape answer.
Which explanation of SOLID do you think is the most misleading? And have you seen SOLID applied in a way that made a codebase worse? Drop it in the comments — I'm collecting the overuse stories.
Top comments (0)