Theory - Composition, SOLID and others

composition

Composition vs Inheritance People sometimes say “composition” when contrasting it with inheritance. This has less to do with functions (which we’ve been discussing all along) and more to do with objects and classes — that is, with traditional object-oriented programming.

In particular, if you express your code as classes, it is tempting to reuse behavior from another class by extending it (inheritance). However, this makes it somewhat difficult to adjust the behavior later. For example, you may want to similarly reuse behavior from another class, but you can’t extend more than one base class.

Sometimes, people say that inheritance “locks you into” your first design because the cost of changing the class hierarchy later is too high. When people suggest composition is an alternative to inheritance, they mean that instead of extending a class, you can keep an instance of that class as a field. Then you can “delegate” to that instance when necessary, but you are also free to do something different.

Function composition is a powerful concept, but it raises the level of abstraction and makes your code less direct. If you write your code in a style that composes functions in some way before calling them, and there are other humans on your team, make sure that you’re getting concrete benefits from this approach. It is not “cleaner” or “better”, and there is a price to pay for “beautiful” but indirect code.

composition article

const dateFunc = () => new Date();
const textFunc = (date) => date.toDateString();
const labelFunc = (text) => `Today ${text}`;
const showLabelFunc = (label) => console.log(label);
 
const date = dateFunc();
const text = textFunc(date);
const label = labelFunc(text);
showLabelFunc(label); // Today Sat Sep 28 2024
 
function pipe(...steps) {
  return function runSteps() {
    let result;
    for (let i = 0; i < steps.length; i++) {
      let step = steps[i];
      result = step(result);
    }
    return result;
  };
}
 
const showDateLabel = pipe(dateFunc, textFunc, labelFunc, showLabelFunc);
showDateLabel(); // Today Sat Sep 28 2024

inheritance

// Base class
class Vehicle {
  private readonly _make: string;
  private readonly _model: string;
  private readonly _year: number;
 
  constructor(make: string, model: string, year: number) {
    this._make = make;
    this._model = model;
    this._year = year;
  }
 
  displayInfo(): string {
    return `${this._year} ${this._make} ${this._model}`;
  }
}
 
// Derived class
class Car extends Vehicle {
  private readonly _doors: number;
 
  constructor(
    make: string,
    model: string,
    year: number,
    doors: number,
  ) {
    super(make, model, year); // Call the constructor of the base class
    this._doors = doors;
  }
 
  displayInfo(): string {
    return `${super.displayInfo()} - ${this._doors} doors`;
  }
}
 
const vehicle = new Vehicle("Toyota", "Corolla", 2020);
console.log(vehicle.displayInfo()); // 2020 Toyota Corolla
 
const car = new Car("Honda", "Civic", 2022, 4);
console.log(car.displayInfo()); // 2022 Honda Civic - 4 doors

Function Stack

The call stack is a critical concept in JavaScript that keeps track of function calls. When a function is called, it is "pushed" onto the stack. Once the function finishes execution, it is "popped" off the stack. This stack-based approach is essential for understanding recursion and function execution order in JavaScript.

In the forward phase, the function calls accumulate as the recursion continues, with each new function invocation pushing itself onto the stack. In the backward phase, as functions start to return, they unwind and are popped from the stack, completing their execution.

This behavior is demonstrated in the following example, where foo is called recursively until it hits the base case, then the stack is unwound as each function completes.

Forward Phase (Pushing):

  1. Call foo(2) -> Stack: [foo(2)].
  2. Call foo(1) -> Stack: [foo(2), foo(1)].
  3. Call foo(0) -> Stack: [foo(2), foo(1), foo(0)].
  4. Call foo(-1) -> Stack: [foo(2), foo(1), foo(0), foo(-1)].

Backward Phase (Unwinding):

  1. Return from foo(-1) -> Stack: [foo(2), foo(1), foo(0)].
  2. Complete foo(0) -> Stack: [foo(2), foo(1)].
  3. Complete foo(1) -> Stack: [foo(2)].
  4. Complete foo(2) -> Stack: [].
function foo(i) {
  if (i < 0) {
    return;
  }
  console.log(`begin: ${i}`);
  foo(i - 1);
  console.log(`end: ${i}`);
}
foo(2);
 
// begin: 2
// begin: 1
// begin: 0
// end: 0
// end: 1
// end: 2

Principles

KISS

Keep it simple

YAGNI

You aren't going to need it

SOLID

Single responsibility principle

Don't create too big components that have too much jobs to do. Break into smaller ones, name = description. Every component has one responsibility, first render card, second contains button that calls the dialog component

import { useState } from 'react'
// bad
const UserCard = ({ user }: { user: User }) => {
  const [open, setOpen] = useState(false)
 
  return (
    <>
      <Dialog
        fullWidth
        maxWidth="md"
        open={open}
        onClose={() => setOpen(false)}
      >
        <EditUserDialog id={user.id} handleEditClose={() => setOpen(false)} />
      </Dialog>
      <Button onClick={() => setOpen(true)}>edit user</Button>
      <Box>...here goes user card</Box>
    </>
  )
}
 
// good
const UserCard = ({ user }: { user: User }) => {
 
  return (
    <>
      <EditUser id={user.id} />
      <Box>...here goes user card</Box>
    </>
  )
}
 
const EditUser = ({ id }: { id: number }) => {
    const [open, setOpen] = useState(false)
 
  return (
    <>
      <Dialog
        fullWidth
        maxWidth="md"
        open={open}
        onClose={() => setOpen(false)}
      >
        <EditUserDialog id={id} handleEditClose={() => setOpen(false)} />
      </Dialog>
      <Button>edit user</Button>
    </>
  )
}

Open/closed principle

Problem with first components is that we can't pass another color without changing FancyIconButton code. In the second case, this problem is fixed, now we don't need to change FancyIconButton2. So, it's open to be used by others, closed to modification

// bad
const FancyIconButton = ({
  red,
  green,
}: {
  red?: boolean
  green?: boolean
}) => {
  const getBackgroundColor = () => {
    switch (true) {
      case red:
        return 'red'
      case green:
        return 'green'
      default:
        return 'black'
    }
  }
 
  return (
    <>
      <IconButton
        color="secondary"
        sx={{ backgroundColor: getBackgroundColor() }}
      >
        <ArrowBackTwoToneIcon />
      </IconButton>
    </>
  )
}
 
// good
const FancyIconButton2 = ({
  backgroundColor = 'black',
}: {
  backgroundColor: string
}) => {
  return (
    <>
      <IconButton color="secondary" sx={{ backgroundColor }}>
        <ArrowBackTwoToneIcon />
      </IconButton>
    </>
  )
}

Liskov substitution principle

FancyIconButton can be replaced by IconButton. Children component inherits parent's behaviours. This way we can have many buttons with different styles, but they all can be used same way as original IconButton

import { IconButton, IconButtonProps } from '@mui/material'
 
const fancyStyles = {}
// bad
const FancyIconButton = () => {
  return (
    <>
      <IconButton sx={{ ...fancyStyles }}>
        <ArrowBackTwoToneIcon />
      </IconButton>
    </>
  )
}
 
// good
const FancyIconButton2 = ({ ...props }: IconButtonProps) => {
  return (
    <>
      <IconButton {...props} sx={{ ...fancyStyles }}>
        <ArrowBackTwoToneIcon />
      </IconButton>
    </>
  )
}
 
const RandomComponent = () => {
  return (
    <>
      {/*error no such props*/}
      <FancyIconButton size={'small'} />
 
      {/*here ok*/}
      <FancyIconButton2 size={'small'} />
      <IconButton onClick={() => null} />
    </>
  )
}

Interface segregation principle

When classes promise each other something, they should separate these promises (interfaces) into many small promises, so it's easier to understand

import { FC } from "react";
 
interface ButtonProps {
  onClick: () => void;
  onMouseOver: () => void;
}
 
const SubmitButton: FC<ButtonProps> = ({ onClick, onMouseOver }) => {
  return (
    <button onClick={onClick} onMouseOver={onMouseOver}>
      Submit
    </button>
  );
};
// This forces `SubmitButton` to support all event handlers, even if not all are needed.
 
// Good
interface Clickable {
  onClick: () => void;
}
 
interface Hoverable {
  onMouseOver: () => void;
}
 
const ClickableButton: FC<Clickable> = ({ onClick }) => {
  return <button onClick={onClick}>Submit</button>;
};
 
const HoverableDiv: FC<Hoverable> = ({ onMouseOver }) => {
  return <div onMouseOver={onMouseOver}>Hover over me!</div>;
};
 
const SubmitButton2: FC<Clickable & Hoverable> = ({ onClick, onMouseOver }) => {
  return (
    <button onClick={onClick} onMouseOver={onMouseOver}>
      Submit
    </button>
  );
};
 
const App = () => {
  return (
    <div>
        <h1>Interface segregation in React</h1>
      {/* both are same */}
      <SubmitButton onClick={()=>null} onMouseOver={()=>null} />
      <SubmitButton2 onClick={()=>null} onMouseOver={()=>null} />
      {/* separated */}
      <ClickableButton onClick={()=>null} />
      <HoverableDiv onMouseOver={()=>null} />
    </div>
  );
};
 
export default App;

Dependency inversion principle

When classes talk to each other in a very specific way, they both depend on each other to never change. Instead, classes should use promises (interfaces, parents), so classes can change as long as they keep the promise

import { FC } from "react";
 
// Abstraction
interface AuthProvider {
  login: (username: string, password: string) => Promise<boolean>;
  logout: () => void;
}
 
// High-level module (React component)
const AuthButton: FC<{ authProvider: AuthProvider }> = ({ authProvider }) => {
  const handleLogin = async () => {
    const success = await authProvider.login("user", "password");
    if (success) {
      console.log("Logged in successfully");
    } else {
      console.error("Login failed");
    }
  };
 
  const handleLogout = () => {
    authProvider.logout();
    console.log("Logged out");
  };
 
  return (
    <div>
      <button onClick={handleLogin}>Login</button>
      <button onClick={handleLogout}>Logout</button>
    </div>
  );
};
 
// Implementation of the abstraction
class FirebaseAuthProvider implements AuthProvider {
  async login(username: string, password: string): Promise<boolean> {
    // Simulate Firebase API call
    console.log(`Logging in with Firebase: ${username}`);
    return username === "user" && password === "password"; // Simulate success for demo
  }
 
  logout(): void {
    console.log("Logging out with Firebase");
  }
}
 
// Another implementation
class LocalAuthProvider implements AuthProvider {
  async login(username: string, password: string): Promise<boolean> {
    console.log(`Logging in locally: ${username}`);
    return username === "admin" && password === "1234"; // Simulate success for demo
  }
 
  logout(): void {
    console.log("Logging out locally");
  }
}
 
// Using the component
const App = () => {
  // Use any implementation of the abstraction
  const authProvider = new FirebaseAuthProvider();
  const localProvider = new LocalAuthProvider();
 
  return (
    <div>
      <h1>Dependency Inversion in React</h1>
      <AuthButton authProvider={authProvider} />
      <AuthButton authProvider={localProvider} />
    </div>
  );
};
 
export default App;

type conversions

Operator precedence

plus

Concatenates strings

const log = (value) => console.log(value);
 
log(null + 1); // 1
log(undefined + 1); // NaN
log(false + 1); // 1
log(true + 1); // 2
log("" + 1); // "1"
log("s" + 1); // "s1"
log("1" + 1); // "11"
 
log(1 + null); // 1
log(1 + undefined); // NaN
log(1 + false); // 1
log(1 + true); // 2
log(1 + ""); // "1"
log(1 + "s"); // "1s"
log(1 + "1"); // "11"

minus

Behaves like other operators, converts to numbers

const log = (value) => console.log(value);
 
log(null - 1); // -1
log(undefined - 1); // NaN
log(false - 1); // -1
log(true - 1); // 0
log("" - 1); // -1
log("s" - 1); // NaN
log("1" - 1); // 0
 
log(1 - null); // 1
log(1 - undefined); // NaN
log(1 - false); // 1
log(1 - true); // 0
log(1 - ""); // 1
log(1 - "s"); // NaN
log(1 - "1"); // 0

comparisons

When comparing values of different types, they convert to numbers

const log = (value) => console.log(value);
 
log(null < 1); // true
log(undefined < 1); // false
log(undefined > 1); // false
log(false < 1); // true
log(true > 0); // true
log("" > 0); // false
log("s" > 1); // false
log("1" > 0); // true
log("12" > "111"); // true
log("12" > 111); // false
log("break");
log(null <= 1); // true
log(null <= 0); // true
log(undefined <= 1); // false
log(undefined >= 1); // false
log(false <= 1); // true
log(true >= 0); // true
log("" >= 0); // true
log("s" >= 1); // false
log("1" >= 0); // true
log("12" >= "111"); // true
log("12" >= 111); // false
log("break");
log("0" >= 0); // true

equality

Strict equality === doesn't convert values

const log = (value) => console.log(value);
 
log(null == null); // true
log(undefined == undefined); // true
log(undefined == null); // true
log(null == undefined); // true
log(0 == false); // true
log(1 == true); // true
log(0 == ""); // true
log(1 == "s"); // false
log(1 == "1"); // true
// [] converted to ''
log(0 == []); // true
log(0 == {}); // false
log("break");
log(null === null); // true
log(undefined === undefined); // true
log(undefined === null); // false
log([] === []); // false

random conversions

const log = (value) => console.log(value);
 
log("10" + 2 * "5"); // 1010
log("" + 0 + 1); // "01"
log("" + 0 - 1); // -1
log("" - 1 + 0); // -1
log(true + false); // 1
log(6 / "3"); // 2
log("2" * "3"); // 6
log(4 + 5 + "px"); // "9px"
log("$" + 4 + 5); // "$45"
log("4" - 2); // 2
log("4px" - 2); // NaN
log("  -9  " + 5); // "  -9  5"
log("  -9  " - 5); // -14
log(null + 1); // 1
log(undefined + 1); // NaN
log(" \t \n" - 2); // -2