

Liskov Substitution Principle in React: Building Reliable Component Hierarchies
Learn how to write Extensible and Predictable code
Welcome back to our SOLID principles series!
If you’ve been following along, we’ve already covered:
✅ S: Single Responsibility Principle (SRP): Keep components focused.
✅ O: Open/Closed Principle (OCP): Make extensions easy, not modifications.
Today, we’re going to talk about L—the Liskov Substitution Principle (LSP), a principle that might sound technical but boils down to one simple idea: keeping things consistent and predictable.
LSP is like an unwritten contract between your components, functions, and hooks. It ensures that you don't break existing behavior when you extend or modify something.
If you’re wondering, "Why should I care?" imagine this:
You build a button component that works everywhere in your app.
Later, you extend it to create a special button—but instead of extending behavior, it removes some functionality (like
onClick
).Now, unexpected errors pop up anywhere this new button is used**.**
That’s an LSP violation in action. Small changes shouldn’t break what’s already working in your web app.
Why Should React Developers Care?
Before we dive into code, here's why LSP matters in your day-to-day React work:
Predictable Components: When components follow LSP, they behave predictably when extended or substituted.
Safer Refactoring: You can confidently refactor and extend components without unexpected side effects.
Type Safety: TypeScript can help enforce LSP, catching potential issues before runtime.
So today, let’s have a look into LSP with real-world examples in React—not just theory but actual problems you’ll run into as a developer and how to fix them the right way.
What Is the Liskov Substitution Principle (LSP)?
LSP is part of the SOLID principles—a set of best practices for writing clean, scalable, and maintainable code.
In simple terms:
A child class (or component) should be able to replace its parent class without breaking the application.
In React terms, this means:
A component or hook should respect the contract of the parent component or function it extends from.
If a function is expecting a base component, you should be able to pass a derived component without issues.
Subclassed or extended components should not remove essential behavior.
If that’s still too abstract, let’s make it very real with a common mistake we often see in frontend development.
In code, this happens when a subclass or extended component fails to replace its parent without breaking expectations. We will see how this plays out in React.
Why is LSP important in React?
When you follow LSP in React, you unlock several benefits:
Predictability: Developers can confidently reuse or extend components without surprises.
Reusability: Extended components are useful across different parts of your app.
Maintainability: Fewer bugs and cleaner relationships between components.
Violating LSP, on the other hand, leads to confusion, unexpected behavior, and headaches when debugging or testing.
LSP in Everyday React Development
Let’s make this real with practical examples. We’ll explore how to apply LSP in common React scenarios and highlight what happens when it’s violated.
1. Extending Components Without Breaking Behavior
Broken Example: A Button That Breaks Expectations
Imagine we have a simple Button
component:
type ButtonProps = {
label: string;
onClick: () => void;
};
export const Button = ({ label, onClick }: ButtonProps) => {
return (
<button onClick={onClick} className="px-4 py-2 bg-blue-500 text-white">
{label}
</button>
);
};
Later, we decide we need a special button that prevents spam clicking.
A developer tries to solve this by creating a new DebouncedButton
:
import { useState } from "react";
export const DebouncedButton = ({ label, onClick }: ButtonProps) => {
const [disabled, setDisabled] = useState(false);
const handleClick = () => {
if (disabled) return;
setDisabled(true);
setTimeout(() => setDisabled(false), 3000); // 3-second delay
onClick();
};
return (
<button onClick={handleClick} disabled={disabled} className="px-4 py-2 bg-green-500 text-white">
{label}
</button>
);
};
Looks harmless, right? Wrong.
Why is this an LSP violation?
Button
originally allowed immediate clicks, but nowDebouncedButton
delays execution.Code that expected immediate action will now behave differently, breaking user expectations.
If someone replaces
<Button>
with<DebouncedButton>
, they could introduce unexpected UX issues.
Fix: Instead of Changing Behavior, Extend It with Props
A better approach is to modify the existing Button and allow debouncing as an optional feature:
import { useState } from "react";
type ButtonProps = {
label: string;
onClick: () => void;
debounce?: boolean;
};
export const Button = ({ label, onClick, debounce = false }: ButtonProps) => {
const [disabled, setDisabled] = useState(false);
const handleClick = () => {
if (debounce) {
if (disabled) return;
setDisabled(true);
setTimeout(() => setDisabled(false), 3000); // 3-second delay
}
onClick();
};
return (
<button onClick={handleClick} disabled={disabled} className="px-4 py-2 bg-blue-500 text-white">
{label}
</button>
);
};
Now, we extend behavior without breaking existing usage:
<Button label="Click Me" onClick={() => console.log("Clicked!")} />
<Button label="Debounced Click" onClick={() => console.log("Clicked!")} debounce />
Why is this better?
The component can still be used normally without debouncing.
We extend behavior instead of changing it.
The
Button
contract remains intact.
2. Extending Hooks Without Breaking Data Contracts
Custom hooks in React are another area where LSP can easily be violated. Let’s say you create a useFetchData
hook to fetch data from an API, and you want to extend it for fetching users.
Broken Example:
// Base Hook
const useFetchData = (url: string) => {
const [data, setData] = useState(null);
useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((data) => setData(data));
}, [url]);
return data;
};
// Extended Hook with Inconsistent Data Structure
const useFetchUsers = (url: string) => {
const data = useFetchData(url);
return { users: data, error: 'Feature not implemented' }; // Unexpected structure
};
The useFetchUsers
hook violates LSP because it changes the return type, making it incompatible with components expecting the original hook's structure.
Fixing the Hook:
const useFetchUsers = (url: string) => {
const data = useFetchData(url);
return data ? { users: data } : null; // Consistent structure
};
By ensuring the return type matches expectations, you make useFetchUsers
a proper substitute for useFetchData
.
Common LSP Violations in React
- Strengthening Preconditions
// Bad: Strengthening preconditions
interface BaseProps {
data?: string[];
}
interface ExtendedProps extends BaseProps {
data: string[]; // Making optional prop required
}
// Good: Maintaining preconditions
interface ExtendedProps extends BaseProps {
onDataLoad?: () => void; // Adding optional features
}
- Weakening Postconditions
// Bad: Weakening return type
interface BaseProps {
getValue: () => string;
}
interface ExtendedProps extends BaseProps {
getValue: () => string | null; // Breaking LSP by possibly returning null
}
// Good: Maintaining return type consistency
interface ExtendedProps extends BaseProps {
getValue: () => string;
getValueOrNull?: () => string | null; // Add new method instead
}
- Breaking Behavioral Contracts
// Bad: Breaking expected behavior
const BaseButton: React.FC<ButtonProps> = ({ onClick }) => (
<button onClick={onClick}>Click Me</button>
);
const ExtendedButton: React.FC<ButtonProps> = ({ onClick }) => (
<button
onClick={(e) => {
e.preventDefault(); // Breaking expected behavior
onClick();
}}
>
Click Me
</button>
);
// Good: Maintaining behavioral contract
const ExtendedButton: React.FC<ButtonProps> = ({ onClick }) => (
<button onClick={onClick} className="extended">
Click Me
</button>
);
How to Avoid LSP Violations in Your Project?
If you’re extending a component, don’t remove expected functionality—just add optional behaviors.
If you modify API responses, make sure they maintain backward compatibility.
Favor props over creating new components if the change is just a small behavior tweak.
By following LSP, your code will be:
1. Predictable – No unexpected side effects.
2. Scalable – New features won’t break old ones.
3. Easier to maintain – Less refactoring, fewer bugs.
Practical Tips for Following LSP in React
- Use TypeScript's
extends
Wisely
// Base props for all form fields
interface FormFieldBase {
name: string;
label?: string;
error?: string;
}
// Extend only when maintaining substitutability
interface TextFieldProps extends FormFieldBase {
type?: 'text' | 'password' | 'email';
}
interface NumberFieldProps extends FormFieldBase {
min?: number;
max?: number;
}
- Favor Composition Over Inheritance
// Instead of inheritance, use composition
interface ValidationProps {
validate?: (value: string) => string | undefined;
}
interface StyleProps {
variant?: 'outline' | 'filled';
size?: 'small' | 'medium' | 'large';
}
// Compose interfaces for different needs
type CustomInputProps = FormFieldBase & ValidationProps & StyleProps;
- Use Default Props Carefully
const CustomInput: React.FC<CustomInputProps> = ({
name,
label,
error,
validate = (value) => undefined,
variant = 'outline',
size = 'medium'
}) => {
// Implementation
};
Final Thoughts: Building Predictable and Scalable React Apps
The Liskov Substitution Principle is all about predictability. By ensuring components and hooks respect the contracts they inherit, you create a codebase that’s easier to understand, maintain, and scale.
LSP isn’t just a theoretical principle—it saves you from debugging nightmares.
This is the third part of our SOLID Principles in React series. Next, we’ll tackle the Interface Segregation Principle (ISP)—a principle that will help you design leaner, more efficient components and interfaces.
Until then, keep your React code SOLID, and let’s build something amazing together.
Stay tuned, and happy coding! 🚀
We at CreoWis believe in sharing knowledge publicly to help the developer community grow. Let’s collaborate, ideate, and craft passion to deliver awe-inspiring product experiences to the world.
Let's connect:
This article is crafted by Chhakuli Zingare, a passionate developer at CreoWis. You can reach out to her on X/Twitter, LinkedIn, and follow her work on the GitHub.