Series · SOLID in React
The Liskov Substitution Principle in Practice: When Two React Components Aren't Really Interchangeable
react · solid · architecture · clean code
TL;DR
- ▸LSP says one implementation can replace another without the caller needing to know which one showed up. In React, the contract is props plus behavior, not just the type.
- ▸TypeScript checks the shape of props and doesn't check behavior: two components with identical signatures can demand more, deliver less, or cause effects the other never causes.
- ▸An extension that requires one extra prop strengthens the precondition; one that silently returns `null` weakens the postcondition. Either way, the `if` comes right back into the component that Open/Closed had just closed.
- ▸The fix is to name the contract as a type, pull extra dependencies inside the extension itself, and turn the empty case into a visible empty state.
- ▸A contract test suite, with the same assertions running against every implementation, is the verifiable half of the principle.
This post is the fourth part of the SOLID in React series, following SOLID in React: An Introductory Guide to the 5 Principles, The Single Responsibility Principle in Practice, and The Open/Closed Principle in Practice.
The previous post ended with UserProfile closed and a slot open. Each screen composes the profile it needs, AdminFields and PartnerFields each live in their own file, and adding a new case no longer means editing the base component.
Then SupplierFields arrived, for suppliers. It fit the same slot, passed the type check, and shipped. Two weeks later, the suppliers screen started showing a profile card with a name, a join date, and nothing underneath. No console error, no failing test, no alert.
SOLID's L badge above three pieces pointing at the same socket, one of them shaped differently and not fitting, contrasted with three identical pieces fitting the same socket
What the principle actually says
If you swap one implementation for another, the caller shouldn't have to know.
Barbara Liskov introduced the idea in 1987 and formalized it in 1994 with Jeannette Wing: if S is a subtype of T, then objects of type T can be replaced by objects of type S without altering any desirable property of the program.
The detail that gets lost in translation to React is that this was never about signatures, it was about behavior. A subtype with exactly the same methods and types still violates the principle if it demands more to work or delivers less than the base type promised.
That's why TypeScript won't save you here. It checks the shape of the props and the shape of the return. It has no way to check that a component renders something visible, that another one doesn't fire a navigation mid-render, that a third one doesn't depend on a field that only exists for half the users. A React component's contract is much larger than its type, and the part that doesn't fit in the type is exactly the part that breaks in production.
The symptom: a fit that compiles and lies
The slot from the previous post has this signature, and everyone reads it as "the contract":
type UserProfileProps = {
userId: string
children?: (user: User) => ReactNode
}
AdminFields and PartnerFields were written against it. So was SupplierFields, almost:
// SupplierFields.tsx: same shape as the other two, different contract
function SupplierFields({ user, onSave }: { user: User; onSave: (data: SupplierData) => void }) {
const { supplier } = useSupplier(user.id)
if (!supplier?.taxId) return null
return (
<label>
Tax ID
<input defaultValue={supplier.taxId} onBlur={(e) => onSave({ taxId: e.target.value })} />
</label>
)
}
Six lines with two broken promises inside.
The first one is onSave. The other two components need nothing but user, this one needs more. The effect shows up at the call site:
<UserProfile userId={id}>{(user) => <AdminFields user={user} />}</UserProfile>
<UserProfile userId={id}>{(user) => <SupplierFields user={user} onSave={saveSupplier} />}</UserProfile>
The slot stopped accepting every extension the same way: two go in one way, the third goes in another.
The second broken promise is return null. The slot's implicit contract was always "renders a section of the profile." SupplierFields honors that contract when the supplier has a registered tax ID, and disappears when it doesn't. That's the empty card that showed up in production, and it's why nothing errored: as far as React is concerned, rendering null is a perfectly valid answer.
There's a third one, more subtle: PartnerFields calls router.push('/onboarding') inside an effect when the partner company isn't approved. Rendering a profile can now take the user off the page, and nothing in the signature warns about it.
The if that comes back through the side door
As long as each screen knows at write time which extension it's using, you can live with all three differences. The problem shows up the day someone needs to treat the three as one thing. That day came when the backoffice started composing the profile from the user's type:
const extensions = {
admin: AdminFields,
partner: PartnerFields,
supplier: SupplierFields,
}
function ProfilePage({ userId, kind }: { userId: string; kind: UserKind }) {
const Extension = extensions[kind]
return <UserProfile userId={userId}>{(user) => <Extension user={user} />}</UserProfile>
}
That code doesn't compile. SupplierFields requires onSave, the generic Extension has no way to supply it, and TypeScript complains about the only one of the three problems it can actually see. The fastest way out, as always, is one if away:
{(user) =>
kind === 'supplier' ? (
<SupplierFields user={user} onSave={saveSupplier} />
) : (
<Extension user={user} />
)
}
And there's the point of this post. The Liskov violation brought back exactly the conditional that Open/Closed had just removed from that spot. That isn't a coincidence, it's the relationship between the two principles: an extension point stays closed only as long as every extension honors the same contract. The moment one of them doesn't, the caller has to tell them apart, and telling them apart means a conditional.
Three extensions with matching shapes and different contracts forcing a conditional on the caller, refactored into a single contract that makes the conditional unnecessary
Why this actually hurts
The three defects fail in different ways, and none of them is loud. The missing prop only surfaces months later, when someone tries to generalize. The silent null becomes an empty card nobody reports as a bug, because it doesn't look broken, it looks empty. The navigation inside an effect becomes a ticket about routing, investigated in the wrong place.
The test suite doesn't help either, for a specific reason: every extension had tests, and they all passed, because they tested the implementation. The SupplierFields test mounted the component with the onSave it asked for and a supplier with a tax ID, precisely the scenario where it works. Nobody tested the slot, because the slot belongs to nobody.
The bigger cost arrives later, and it's about trust. Once the caller has to ask which implementation showed up, the abstraction turned into a union wearing an interface as a costume. Defensive conditionals multiply, and the slot that existed so new cases could arrive without touching what was already there becomes the place where every new case has to be declared all over again.
The mental test here is different from the previous ones. With SRP I asked how many reasons to change lived in that file. With OCP, whether adding the next case meant editing the file or creating a new one. Here it's: does the caller need to know which implementation it got? If the answer is yes, those implementations aren't substitutable, and the slot pretending they are is telling a lie.
Preconditions and postconditions, translated into props
Liskov and Wing's formalization rests on three rules, and all three translate directly to React:
Liskov's three rules translated to props: preconditions can't be strengthened, postconditions can't be weakened, invariants must be preserved
- ▸Preconditions can't be strengthened. The substitute can't demand more than the contract demands. Not one extra required prop, not needing to sit inside a specific Provider, not depending on an optional
userfield only some users have. - ▸Postconditions can't be weakened. The substitute can't deliver less than the contract promises. If the contract is to render a section,
return nulldelivers less; if it's to callonChangeon every edit, calling it only on blur delivers less. - ▸Invariants must be preserved. The substitute can't do what the others don't. Navigating, opening a modal, writing to storage on first render: all of it changes what the caller can assume when that space gets rendered.
In practice, all three ask for the same thing: whoever writes an extension needs to know they're writing against a contract, not against the specific screen they had in mind that day.
Writing the contract before the fit
The first step is the cheapest of all, and it's what had been missing since the previous post: give the contract a name.
// ProfileExtension.ts
import type { ReactNode } from 'react'
/**
* Contract for every profile extension.
* Takes only the user and always renders a visible section.
* Its own data and persistence stay inside the extension.
* No extension navigates or opens a modal during render.
*/
export type ProfileExtension = (props: { user: User }) => ReactNode
Half of that definition is a type, the other half a comment. Only the first half is something the compiler enforces, but writing the second already changes things: now there's a place where whoever builds the fourth extension finds out what's expected of them.
With the contract written down, SupplierFields becomes this:
// SupplierFields.tsx: same shape, now the same contract
export const SupplierFields: ProfileExtension = ({ user }) => {
const { supplier, save } = useSupplier(user.id)
if (!supplier?.taxId) {
return <EmptyField label="Supplier details" hint="Registration pending approval" />
}
return (
<label>
Tax ID
<input defaultValue={supplier.taxId} onBlur={(e) => save({ taxId: e.target.value })} />
</label>
)
}
onSave left the signature and moved into useSupplier, the same move AdminFields already made in the previous post: whoever needs the data fetches the data, whoever needs to persist owns the persistence. The precondition is back in line with the other two.
The return null became an empty state. The empty card in production was never a layout bug, it was the interface hiding from the user that there's a registration waiting for approval. Honoring the postcondition and showing why the space is empty is the right architectural call and the right product call at once.
In PartnerFields, the navigation moves out of the effect and becomes what it always was: a decision belonging to the screen, not the field. The component renders the pending-contract notice with a link, and the route decides whether to redirect. Now the map compiles with no conditional at all:
const extensions: Record<UserKind, ProfileExtension> = {
admin: AdminFields,
partner: PartnerFields,
supplier: SupplierFields,
}
Record<UserKind, ProfileExtension> forces every new extension to declare itself as a ProfileExtension before it can enter the map. The compiler guarantees the shape from there on; it doesn't guarantee behavior, which is exactly why behavior needs a test.
Testing the contract, not the implementation
What was missing wasn't more tests, it was a different kind of test: written once, against the contract, and run against every implementation.
// profile-extension.contract.test.tsx
describe.each(Object.entries(extensions))('ProfileExtension contract: %s', (name, Extension) => {
it('renders a visible section even without the optional data', () => {
const { container } = render(<Extension user={userWithoutOptionalData} />)
expect(container).not.toBeEmptyDOMElement()
})
it('does not navigate during render', () => {
render(<Extension user={userWithoutOptionalData} />)
expect(navigate).not.toHaveBeenCalled()
})
})
That file is the only thing in the project that knows the three extensions are supposed to be interchangeable. It's what breaks when the fourth one arrives carrying a new demand, and it breaks in the right place, with the name of the guilty implementation in the output. It's the same idea as contract tests between services, applied inside the front end: the test belongs to the contract, not to whoever implements it.
A contract test matrix: the same two assertions running against AdminFields, PartnerFields, and SupplierFields, all passing
It's worth writing that file before the fourth extension, not after the first incident. It's short, it ages well, and it grows with the contract: every new rule that lands in the ProfileExtension comment should land here as an assertion.
Where the line gets drawn
Liskov only applies where substitution actually happens. Two components that never compete for the same spot owe each other nothing, however similar their names or props: there's resemblance, not a contract.
The failure in the opposite direction is more common: forcing a shared contract onto things that aren't the same thing. The warning sign is the contract collecting optional props only one implementation reads, or an escape hatch like extra?: unknown for the odd case. A contract with an escape hatch is a union in disguise. At that point, the right move is to admit these are two different things and give them two slots, even if it means repeating some structure: duplication is cheaper than the wrong abstraction.
It's also worth not confusing contract with visual uniformity. Two extensions can render completely different things, a table and a text field, and still honor the same contract: what has to match is the input requirements, the output guarantee, and the effects they cause.
What's next
With the contract written and tested, the next pressure is easy to predict: it will want to grow. The fourth extension needs the user's permissions, the fifth needs the card's layout, and the quickest way out is to fatten ProfileExtension until everyone fits. Before long, every extension receives a handful of props it never reads just to satisfy the signature, and whoever writes the next one can no longer tell what's required from what's decoration. That's what the series' next letter is about, the I for Interface Segregation.
References
- ▸Barbara Liskov, Data Abstraction and Hierarchy (1987), the OOPSLA keynote where the idea first appears
- ▸Barbara Liskov and Jeannette Wing, "A Behavioral Notion of Subtyping" (1994), the formalization in terms of preconditions, postconditions, and invariants
- ▸Robert C. Martin, "The Liskov Substitution Principle", the original article from the C++ Report (1996)
- ▸Sandi Metz, "The Wrong Abstraction" (2016)
Comments
Related Posts



