DEV Community

Cover image for Liskov Substitution Principle in React: Building Reliable Component Hierarchies
Chhakuli Zingare for CreoWis

Posted on • Originally published at creowis.com

Liskov Substitution Principle in React: Building Reliable Component Hierarchies

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:

  1. Predictable Components: When components follow LSP, they behave predictably when extended or substituted.

  2. Safer Refactoring: You can confidently refactor and extend components without unexpected side effects.

  3. 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:

  1. A component or hook should respect the contract of the parent component or function it extends from.

  2. If a function is expecting a base component, you should be able to pass a derived component without issues.

  3. 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:

  1. Predictability: Developers can confidently reuse or extend components without surprises.

  2. Reusability: Extended components are useful across different parts of your app.

  3. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Looks harmless, right? Wrong.

Why is this an LSP violation?

  1. Button originally allowed immediate clicks, but now DebouncedButton delays execution.

  2. Code that expected immediate action will now behave differently, breaking user expectations.

  3. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

Why is this better?

  1. The component can still be used normally without debouncing.

  2. We extend behavior instead of changing it.

  3. 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
};
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

By ensuring the return type matches expectations, you make useFetchUsers a proper substitute for useFetchData.

Common LSP Violations in React

  1. 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
}
Enter fullscreen mode Exit fullscreen mode
  1. 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
}
Enter fullscreen mode Exit fullscreen mode
  1. 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>
);
Enter fullscreen mode Exit fullscreen mode

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

  1. 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;
}
Enter fullscreen mode Exit fullscreen mode
  1. 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;
Enter fullscreen mode Exit fullscreen mode
  1. Use Default Props Carefully
const CustomInput: React.FC<CustomInputProps> = ({
  name,
  label,
  error,
  validate = (value) => undefined,
  variant = 'outline',
  size = 'medium'
}) => {
  // Implementation
};
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (3)

Collapse
 
wizard798 profile image
Wizard

I was eagerly waiting for this, thanks man, you're doing great job, to first explain real issue with examples and why its bad, then provides solution of it and with most important why its good to follow those steps you provided for better react apps.

As a person who just learned React got it enclopedia of what to do and what not got here, instead of making posts like 15 tricks to improve react performance and blah blah blah ( no hate towards any other ), and showing one two liners explanation are not informative, they look more like opinited tricks due to lack of information, but with your these posts they are actually more appealing ( at least to me ), i feel like I'm getting more knowledge from these

Once again, thanks for this and can't wait for next

Collapse
 
chhakuli_zingare_2a0ad5f8 profile image
Chhakuli Zingare

Thank you so much for your thoughtful words! It really means a lot to me. Honestly, a lot of these lessons come from my own mistakes of the past. I didn’t always think about how adding new features could affect the whole codebase. It’s so easy to get caught up in the excitement of building and forget the importance of scalability and maintainability.

I’ve learned the hard way that when I take time to think through things like SOLID principles and focus on making my code more adaptable, it saves me a lot of headaches in the long run. That's what motivates me to share these blogs to help others avoid some of the challenges I faced and hopefully make their projects more scalable and easier to manage.

Thanks again for the comment it really pushes me to keep going. Looking forward to sharing more!

Collapse
 
wizard798 profile image
Wizard

Very greatful. Thank you so much, these principles were gonna help me in my first standalone project I'm starting in few days, I'll consider these to make my code more scalable, adaptive and readable and maintainable.