Skip to main content

Command Palette

Search for a command to run...

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.

Updated
5 min read

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

  1. SRP: Don't be a hero. Do one thing.

  2. OCP: Extend, don't edit.

  3. LSP: Don't break the contract.

  4. ISP: Stop over-sharing data.

  5. 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. ✌️