YES! SOLID principles can and should be applied to front-end development. Although originally developed for object-oriented programming, their concepts are easily adaptable and useful for structuring code in front-end applications, especially when using component-oriented libraries or frameworks such as React, Angular, or Vue.
Strangely enough, when I ask front-end developers about SOLID in a job interview, they don’t know what to answer. So, I’d like to break down each principle and explain why it should be applied in the front end.
Here is how each SOLID principle can be applied to frontend development with an example (this is just an example of applying the principle, not working code):
Single Responsibility Principle (SRP)
- Concept: A class or function should have only one responsibility
- In frontend: Each UI component or feature should do one task well. For example, in React, a component should focus on a single task — rendering a button, displaying a user profile, or controlling form input — and not take on additional logic such as data fetching or routing unless required.
interface ButtonProps {
label: string;
onClick: () => void;
}
const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
// Logic is kept outside of the Button
const App: React.FC = () => {
const handleClick = () => {
console.log("Button clicked");
};
return <Button label="Click Me" onClick={handleClick} />;
};
Open/Closed Principle (OCP)
- Concept: Software entities (classes, functions, modules) should be open for extension but closed for modification.
- In frontend: A component should be designed so that it can be extended without having to make changes to the source code. For example, prop passing in React allows you to change the behavior of a component without changing its core logic, making it more reusable. But it is also possible to break a component into smaller components, as is done in the Shadcn library.
interface ButtonProps {
label: string;
onClick: () => void;
style?: React.CSSProperties;
}
const Button: React.FC<ButtonProps> = ({ label, onClick, style }) => {
return <button style={style} onClick={onClick}>{label}</button>;
};
// Extending behavior by passing different props
const App: React.FC = () => {
return (
<div>
<Button
label="Primary"
onClick={() => alert("Primary Button Clicked")}
style={{ backgroundColor: "blue", color: "white" }}
/>
<Button
label="Secondary"
onClick={() => alert("Secondary Button Clicked")}
style={{ backgroundColor: "gray", color: "white" }}
/>
</div>
);
};
Or splitting a component into several smaller components.
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
Liskov Substitution Principle (LSP)
- Concept: Superclass objects must be replaceable by subclass objects without sacrificing functionality.
- In frontend: In practice, this means that components or functions should be easily interchangeable as long as they adhere to the same interface or contract. For example, if you have different form components (e.g. login form, registration form), they should work easily when placed in a layout component, as long as they match the expected inputs and outputs.
interface InputProps {
type?: string;
placeholder: string;
}
const Input: React.FC<InputProps> = ({ type = "text", placeholder }) => {
return <input type={type} placeholder={placeholder} />;
};
// Derived component behaves like Input
const PasswordInput: React.FC<InputProps> = (props) => {
return <Input type="password" {...props} />;
};
// Both Input and PasswordInput are interchangeable
const App: React.FC = () => {
return (
<div>
<Input placeholder="Enter your name" />
<PasswordInput placeholder="Enter your password" />
</div>
);
};
Interface Segregation Principle (ISP)
- Concept: The client should not be forced to depend on methods it does not use.
- In frontend: This means that components, hooks, or useful functions should be kept to a minimum so that they only expose what is needed. For example, a button component can provide customizable properties such as onClick and label, but not expose unnecessary props or methods that would complicate its use.
interface UsernameInputProps {
username: string;
onUsernameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const UsernameInput: React.FC<UsernameInputProps> = ({ username, onUsernameChange }) => {
return <input value={username} onChange={onUsernameChange} placeholder="Username" />;
};
interface PasswordInputProps {
password: string;
onPasswordChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const PasswordInput: React.FC<PasswordInputProps> = ({ password, onPasswordChange }) => {
return <input value={password} onChange={onPasswordChange} placeholder="Password" type="password" />;
};
// Using segregated components
const App: React.FC = () => {
const [username, setUsername] = React.useState("");
const [password, setPassword] = React.useState("");
return (
<div>
<UsernameInput username={username} onUsernameChange={(e) => setUsername(e.target.value)} />
<PasswordInput password={password} onPasswordChange={(e) => setPassword(e.target.value)} />
</div>
);
};
Dependency Inversion Principle (DIP)
- Concept: High-level modules should not depend on low-level modules, but both should depend on abstractions.
- In frontend: Instead of hard-coding dependencies within components, it’s better to rely on abstractions such as context providers, hooks, or dependency injections. This can make your code more modular and testable. For example, a component should depend on an abstract API service rather than directly calling an HTTP client such as fetch or axios.
// Low-level module (API service)
const ApiService = {
fetchUser: async (): Promise<{ name: string }> => {
const response = await fetch("/api/user");
return response.json();
},
};
interface UserProfileProps {
fetchUser: () => Promise<{ name: string }>;
}
const UserProfile: React.FC<UserProfileProps> = ({ fetchUser }) => {
const [user, setUser] = React.useState<{ name: string } | null>(null);
React.useEffect(() => {
fetchUser().then(setUser);
}, [fetchUser]);
if (!user) return <div>Loading...</div>;
return <div>Welcome, {user.name}</div>;
};
// Injecting the dependency via props
const App: React.FC = () => {
return <UserProfile fetchUser={ApiService.fetchUser} />;
};
Benefits of Applying SOLID in Frontend
- Maintainability: The code becomes easier to manage and extend as the project grows.
- Testability: Components and functions that follow SOLID are more modular and easier to test.
- Reusability: Properly designed components and modules can be reused across different parts of the application.
- Scalability: SOLID codebase is easier to scale because new features can be added without significantly changing existing code.
If you disagree or have something to add to this, please leave your comments. Thank you for reading!