SOLID Principles Applied to React and TypeScript
Understand and apply all five SOLID principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — using practical React component and hook examples in TypeScript.
SOLID was coined for object-oriented class design, but every principle maps cleanly onto React components, hooks, and TypeScript interfaces once you translate "class" into "component or hook" and "inheritance" into "composition and props." Applied well, SOLID produces components that are easy to test in isolation, easy to extend without editing existing code, and easy to swap for alternate implementations. Applied dogmatically, it produces over-engineered abstractions — so each principle below comes with a concrete, proportionate React/TypeScript example, not an abstract class diagram.
S — Single Responsibility Principle
A component or hook should have one reason to change. In React terms: a component that fetches data, validates a form, formats currency, and renders a table has four separate reasons someone might need to edit it, and editing one responsibility risks breaking another by accident.
The fix is the same as in the Clean Code lesson: separate data-fetching into a hook, formatting into a pure utility function, and rendering into a focused component. SRP is the foundation the other four principles build on — you cannot meaningfully apply Open/Closed or Dependency Inversion to a component that is already doing five unrelated things.
O — Open/Closed Principle
A component should be open for extension but closed for modification: you should be able to add new behavior without editing the component's existing, already-tested code. In React, this usually means designing around composition (children, render props, a variant prop backed by a lookup table) instead of a growing chain of if/else branches that gets edited every time a new case appears.
A classic smell is a Button component with an ever-growing if (type === "danger") ... else if (type === "warning") ... else if (type === "new-thing") chain. Every new button style means editing and re-testing the whole component. A lookup-table or composition-based design lets you add a new variant without touching existing code paths at all.
L — Liskov Substitution Principle
Any implementation that satisfies an interface should be substitutable for another implementation of that interface without breaking the caller. In React/TypeScript, this shows up most clearly in props typed by a shared interface: if PrimaryButton and IconButton both implement a ButtonProps interface, swapping one for the other anywhere in the tree must not require the parent to add special-case logic.
It also applies to hooks with a shared return shape: if useLocalStorage and useSessionStorage both implement the same [value, setValue] contract as useState, a component using either one should work identically regardless of which is passed in. A violation looks like a component that special-cases "if this is the mock implementation, do X" — that means the mock is not truly substitutable.
I — Interface Segregation Principle
No component or hook should be forced to depend on props or methods it does not use. A single giant UserCardProps interface with 20 optional fields, where every consumer only ever uses 3-4 of them, is a segregation violation — it is hard to know which fields matter for a given usage, and every consumer pays the cognitive cost of the full interface.
The fix is to split large interfaces into smaller, focused ones that compose, and to design components/hooks around the minimum data they actually need — often just the two or three fields required, rather than an entire domain object.
D — Dependency Inversion Principle
High-level components should not depend on low-level implementation details directly — both should depend on an abstraction. In React, this means a component should receive its data-fetching logic, API client, or storage mechanism through props, context, or a hook parameter, rather than importing and calling a concrete fetch("/api/...") directly inside its body.
This is what makes components testable without a real network and swappable for different backends (REST today, GraphQL tomorrow, a mock in Storybook) without editing the component itself — only the injected dependency changes.
Dependency Inversion via an injected service interface
UserProfile never imports fetch or knows about REST — it depends only on the UserRepository interface. Swapping RestUserRepository for MockUserRepository requires zero changes inside UserProfile — this is Dependency Inversion and Liskov Substitution working together.
// The abstraction both the component and any implementation depend on:
interface UserRepository {
getUser(id: string): Promise<User>;
}
// Low-level detail #1: real REST implementation
class RestUserRepository implements UserRepository {
async getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error('Failed to load user');
return res.json();
}
}
// Low-level detail #2: test/mock implementation, same shape
class MockUserRepository implements UserRepository {
constructor(private users: Record<string, User>) {}
async getUser(id: string): Promise<User> {
const user = this.users[id];
if (!user) throw new Error('Not found');
return user;
}
}
// High-level component depends only on the abstraction:
function UserProfile({ userId, repo }: { userId: string; repo: UserRepository }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => { repo.getUser(userId).then(setUser); }, [userId, repo]);
return user ? <div>{user.name}</div> : <LoadingSpinner />;
}
// Production: <UserProfile userId="1" repo={new RestUserRepository()} />
// Test: <UserProfile userId="1" repo={new Mock
…Code Examples
Because the styling is driven by a lookup table keyed on the variant type, adding a new variant is a data change, not a logic change — the Button component's rendering code is never edited again, satisfying "open for extension, closed for modification." const VARIANT_STYLES: Record<ButtonProps['variant'], string> = {... };
Both hooks satisfy the same StorageHook<T> contract and are fully substitutable: useDraftText behaves identically regardless of which one it receives, with no conditional logic checking "which storage am I using." That interchangeability, with no caller-side special-casing, is the Liskov Substitution Principle in practice.
UserAvatarBadge no longer depends on follower counts or action callbacks it never uses; each component's prop type documents precisely what it needs. Splitting the interface means a change to UserSocialStats cannot possibly affect UserAvatarBadge, which never referenced it.
Common mistake: Passing (and typing against) an entire domain object when a component only reads two of its fields, coupling unrelated components to unrelated changes.
interface ButtonProps {
variant: 'primary' | 'danger' | 'ghost';
children: React.ReactNode;
onClick?: () => void;
}
// Closed for modification: this map is the only thing touched to add a variant.
const VARIANT_STYLES: Record<ButtonProps['variant'], string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
ghost: 'bg-transparent text-blue-600 hover:bg-blue-50',
};
function Button({ variant, children, onClick }: ButtonProps) {
return (
<button className={`px-4 py-2 rounded ${VARIANT_STYLES[variant]}`} onClick={onClick}>
{children}
</button>
);
}
// Adding a new "warning" variant later means adding one line to VARIANT_STYLES
// and one value to the union type -- the component body itself never changes.type StorageHook<T> = (key: string, initial: T) => [T, (value: T) => void];
function useLocalStorage<T>(key: string, initial: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
const set = (v: T) => { setValue(v); localStorage.setItem(key, JSON.stringify(v)); };
return [value, set];
}
function useSessionStorage<T>(key: string, initial: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
const stored = sessionStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
const set = (v: T) => { setValue(v); sessionStorage.setItem(key, JSON.stringify(v)); };
return [value, set];
}
// A consumer written against the StorageHook shape works with either,
// with no special-casing for which one was passed in.
function useDraftText(useStorage: StorageHook<string>) {
const [draft, setDraft] = useStorage('draft-text', '');
return { draft, setDraft };
}
// useDraftText(useLocalStorage) -- persists across tabs/sessions
// useDraftText(useSessionStorage) -- persists only for the tab's session// Before: every consumer depends on the full shape, even if they use 3 of 12 fields.
interface UserCardPropsBad {
id: string; name: string; email: string; avatarUrl: string;
bio: string; joinedAt: string; role: string; isOnline: boolean;
followerCount: number; followingCount: number; onFollow: () => void; onMessage: () => void;
}
// After: segregated into focused interfaces that compose only what's needed.
interface UserIdentity { name: string; avatarUrl: string; }
interface UserPresence { isOnline: boolean; }
interface UserSocialStats { followerCount: number; followingCount: number; }
interface UserActions { onFollow?: () => void; onMessage?: () => void; }
// A compact card only needs identity + presence:
function UserAvatarBadge({ name, avatarUrl, isOnline }: UserIdentity & UserPresence) {
return (
<div className="avatar-badge">
<img src={avatarUrl} alt={name} />
{isOnline && <span className="dot" />}
</div>
);
}
// A full profile card composes everything it actually needs, nothing more:
function UserProfileCard(props: UserIdentity & UserSocialStats & UserActions) {
return (
<div>
<img src={props.avatarUrl} alt={props.name} />
<
…SOLID principles mapped to React/TypeScript
S Single Responsibility -> one hook/component, one job
O Open/Closed -> add variants via composition/config, not edited if-chains
L Liskov Substitution -> interchangeable props/hook shapes, no special-casing
I Interface Segregation -> small focused prop interfaces, not one giant do-everything type
D Dependency Inversion -> inject data/services via props or context, do not hardcode fetch()Important Rules
Single Responsibility: a hook or component should be describable in one sentence, with one reason to change.
Open/Closed: prefer a lookup table, config, or composition over an editable if/else chain for adding new variants.
Liskov Substitution: any two implementations of the same prop or hook shape must be swappable with zero special-casing at the call site.
Interface Segregation: split large prop interfaces into small, focused ones a component only depends on what it actually uses.
Dependency Inversion: inject data sources and services via props/context/hook params; never hardcode a concrete fetch call inside a component you want to test or reuse.
Where SOLID earns its keep in React
Design-system component libraries lean heavily on Open/Closed (adding a new button variant should never require editing Button.tsx) and Interface Segregation (a Card should not require every prop a Modal needs). Data-fetching layers lean on Dependency Inversion to stay testable — teams that inject their API clients can run entire component test suites with zero real network calls, which is usually the single biggest speed win in a frontend test suite.
function OrderSummary({ user }: { user: FullUserProfile }) { return <p>{user.name}</p>; } function OrderSummary({ userName }: { userName: string }) { return <p>{userName}</p>; }
Depending on the whole FullUserProfile object to read one field (Interface Segregation violation) couples this component to every future change of the user profile shape, most of which it does not care about.
function ReportChart() { const data = fetch("/api/report").then(...);... } function ReportChart({ dataSource }: { dataSource: () => Promise<ReportData> }) {... }
Hardcoding the fetch call inside the component (Dependency Inversion violation) makes it impossible to test with mock data or reuse against a different endpoint without editing the component itself.
A React Button component has a long if/else chain checking variant, and every new visual style requires editing that chain. Which SOLID principle does this violate? A. Single Responsibility Principle B. Open/Closed Principle C. Liskov Substitution Principle D. Interface Segregation Principle
B. Open/Closed Principle — The component is not "closed for modification" — every new variant forces an edit to existing, already-tested code. A lookup table or composition-based design would let it stay closed while remaining open to new variants.
What does it mean for a component to violate the Liskov Substitution Principle? A. It has too many props B. It fetches data directly instead of receiving it via props C. Swapping one implementation of a shared interface for another (e.g. a mock hook for a real one) breaks the consuming component or requires special-case logic D. It renders more than 150 lines of JSX
C. Swapping one implementation of a shared interface for another (e.g. a mock hook for a real one) breaks the consuming component or requires special-case logic — Liskov Substitution requires that any implementation satisfying a given interface or hook shape be fully interchangeable with another, with no caller-side special-casing needed for either one to work correctly.
Why does injecting a data-fetching dependency via props (Dependency Inversion) make components easier to test? A. It makes the component render faster B. It removes the need for TypeScript types C. It lets tests substitute a mock implementation of the dependency, avoiding real network calls D. It automatically generates unit tests
C. It lets tests substitute a mock implementation of the dependency, avoiding real network calls — When a component depends on an injected abstraction (like a UserRepository interface or a dataSource prop) rather than a hardcoded fetch call, tests can pass in a mock implementation and exercise the component's logic without touching a real network.
Take a component in your own codebase that calls fetch directly inside a useEffect. Refactor it to accept a dataSource: () => Promise<T> prop (Dependency Inversion), and write a second "implementation" — a function returning mock data — that you can pass in for a Storybook story or test, with zero changes to the component itself. Hint: Start by extracting the exact shape currently returned by your fetch call into a TypeScript interface; that becomes your abstraction (the interface both real and mock implementations satisfy).
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.