From Messy to Maintainable: SOLID Principles in JavaScript, React, and Node.js
Stop letting AI write your architecture. Let me give you a no-nonsense guide to writing clean, maintainable code in the React & Node.js ecosystem.
In this age of AI vibe coder, everyone's a "Senior Engineer" until npm run build fails and the terminal starts screaming in red text.
We get it. ChatGPT wrote 80% of your codebase. Copilot is your co-founder. But here’s the tea: AI is great at generating code, but it is terrible at architecture. It will happily hand you a monolithic app.ts file that looks like a bowl of overly sauced spaghetti.
If you want your React/Node app to survive past the MVP stage without needing a full rewrite (RIP), you need principles like SOLID. It’s not just boomer tech wisdom; it’s the difference between a codebase that "slaps" and one that gets you roasted in the code review.
Let’s break it down, TypeScript style.
S — Single Responsibility Principle (SRP)
The Vibe: "You're doing too much."
A function or component should do one thing and do it well. If your React component is fetching data, validating forms, managing 4 useEffect hooks, and rendering the UI... it’s cooked.
The "Mid" Way (React Anti-Pattern): You have a Dashboard.tsx that is 400 lines long. It fetches users, filters them, handles the loading spinner, and renders the list.
// 🚩 Red Flag: Why is this component doing everything?
const Dashboard = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users').then(...) // Fetching logic? inside UI?
}, []);
return (
<div>
{/* 100 lines of JSX */}
</div>
)
}
The "W" Way (Separation of Concerns): Split that messy breakup into two peaceful co-parents: Logic (Hooks) and Looks (UI).
// 1. The Brains (Custom Hook)
const useUsers = () => {
// All the messy fetch/state logic lives here
return useQuery({ queryKey: ['users'], queryFn: fetchUsers });
}
// 2. The Beauty (UI Component)
const UserList = ({ users }: { users: User[] }) => {
// Pure UI. No logic. Just vibes.
return users.map(u => <Card key={u.id} user={u} />);
}
// 3. The Page
const Dashboard = () => {
const { data } = useUsers();
return <UserList users={data} />;
}
Why it hits: You can change the UI without breaking the data fetching. You can fix the API call without breaking the CSS. Peace of mind.
O — Open/Closed Principle (OCP)
The Vibe: "New phone, who dis? (Don't change me, extend me)"
Your code should be Open for extension, but Closed for modification.
Basically, if your Product Manager asks for a new feature (e.g., "Add 'Gold' users!"), you shouldn't have to go into your existing, working code and start hacking away at if/else statements. That’s how bugs are born.
The "L" Way (Node/Express): The dreaded switch statement of doom.
// 🚩 Every time we add a new role, we have to edit this file.
const calculateBonus = (role: string) => {
if (role === 'admin') return 1000;
if (role === 'manager') return 500;
if (role === 'intern') return -100; // minimal wage lol
// Adding 'super-admin'? Gotta edit this file. Risky.
}
The "Glow Up" (Object Maps / Strategy Pattern): Use an object map or a class strategy.
// ✅ Logic is separated.
const bonusStrategies: Record<string, number> = {
admin: 1000,
manager: 500,
intern: 0,
};
const calculateBonus = (role: string) => {
return bonusStrategies[role] || 0;
}
// Want to add a new role?
// Just extend the object config. The function logic stays untouched.
bonusStrategies['super-admin'] = 9000;
L — Liskov Substitution Principle (LSP)
The Vibe: "Don't catfosh me."
If something looks like a Duck (a specific Type/Interface), it better quack like a Duck. If you swap a parent class with a child class, the app shouldn't crash.
The "Sus" Way (React/TS): You make a CoolButton that extends the normal HTML Button, but you decide to break the rules.
// 🚩 This breaks the expectation of a button
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
// forcing a specific prop that native buttons don't have
verificationCode: string;
}
const CoolButton = ({ onClick, verificationCode, ...props }: Props) => {
if (!verificationCode) {
// 💥 IT CRASHES or does nothing if this specific prop is missing
// A normal button wouldn't do this.
throw new Error("Bro where is the code??");
}
return <button onClick={onClick} {...props} />;
}
The Fix: Ensure your custom component creates a predictable experience that matches the base type. If it extends Button, it should act like a Button.
I — Interface Segregation Principle (ISP)
The Vibe: "Don't be a stage 5 clinger."
Don't force a component (or function) to depend on data it doesn't need. Stop passing the entire User object when the component only needs the firstName.
The "Messy" Way (TypeScript):
interface GOD_USER_OBJECT {
id: string;
name: string;
email: string;
passwordHash: string;
socialSecurityNumber: string; // 💀
grandmotherMaidenName: string;
}
// 🚩 Why does the Navbar need the SSN??
const Navbar = (props: { user: GOD_USER_OBJECT }) => {
return <nav>Hello, {props.user.name}</nav>;
}
The "Clean" Way: Use TypeScript utility types like Pick or create smaller interfaces.
// ✅ Only ask for what you need
type NavbarProps = Pick<GOD_USER_OBJECT, 'name'>;
const Navbar = ({ name }: NavbarProps) => {
return <nav>Hello, {name}</nav>;
}
Why it hits: If the backend schema changes the passwordHash field, your Navbar won't break because it never cared about it in the first place.
D — Dependency Inversion Principle (DIP)
The Vibe: "Don't catch feelings for low-level details."
High-level modules (your business logic) shouldn't depend on low-level modules (database connections, specific libraries). Both should depend on abstractions (interfaces).
The "Toxic Relationship" (Node/Express): Hardcoding your database inside your controller.
import { db } from './postgres-driver'; // 🚩 Hard dependency
const createPost = async (title: string) => {
// If we switch to MongoDB tomorrow, we have to rewrite this ENTIRE function.
return await db.query(`INSERT INTO posts...`);
}
The "Independent" Way (Dependency Injection): Pass the DB capability into the function.
// 1. Define the Interface (The Contract)
interface IPostRepository {
save(post: Post): Promise<void>;
}
// 2. The Logic (doesn't care if it's SQL or Mongo)
class PostService {
constructor(private repo: IPostRepository) {}
async createPost(title: string) {
// logic...
await this.repo.save({ title });
}
}
// 3. Inject it at runtime
const service = new PostService(new PostgresRepo());
// Switching to Mongo?
// const service = new PostService(new MongoRepo());
TL;DR
SRP: Don't be a hero. Do one thing.
OCP: Extend, don't edit.
LSP: Don't break the contract.
ISP: Stop over-sharing data.
DIP: Depend on interfaces, not implementations.
Write clean code so your future self doesn't have to decipher what kind of caffeine-induced mania you were in when you wrote that utils.ts file. ✌️

