Skip to content

03 - SOLID Principles in React ​

Why This Matters for Glomopay ​

HR explicitly listed SOLID. In a fintech codebase with checkout SDKs, compliance tools, and merchant dashboards, clean architecture is not optional — it prevents bugs in money-related code and makes the codebase maintainable as the team grows.


S — Single Responsibility Principle ​

"A component/hook should have one reason to change."

Bad: One component doing everything ​

tsx
function CheckoutPage() {
  const [items, setItems] = useState<CartItem[]>([]);
  const [paymentMethod, setPaymentMethod] = useState<string>('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Fetches cart
  useEffect(() => { /* fetch logic */ }, []);

  // Validates form
  const validate = () => { /* validation logic */ };

  // Submits payment
  const handleSubmit = async () => { /* payment API call */ };

  // Renders EVERYTHING — cart, form, summary, payment
  return (
    <div>
      {/* 200 lines of JSX mixing cart, form, summary, payment */}
    </div>
  );
}

Good: Each piece has one job ​

tsx
// Page — only composes children
function CheckoutPage() {
  return (
    <CheckoutProvider>
      <CartSummary />
      <PaymentMethodSelector />
      <OrderReview />
      <PaymentButton />
    </CheckoutProvider>
  );
}

// Hook — only data fetching
function useCart(userId: string) {
  return useQuery({ queryKey: ['cart', userId], queryFn: () => api.getCart(userId) });
}

// Hook — only validation
function useCheckoutValidation(state: CheckoutState) {
  return useMemo(() => ({
    isValid: state.amount > 0 && state.paymentMethod !== null,
    errors: validate(state),
  }), [state]);
}

// Component — only renders cart items
function CartSummary() {
  const { state } = useCheckout();
  return <ul>{state.items.map(item => <CartItem key={item.id} item={item} />)}</ul>;
}

How to spot violations ​

  • Component file > 200 lines
  • Multiple useEffects doing unrelated things
  • Mixing data fetching + rendering + business logic

O — Open/Closed Principle ​

"Open for extension, closed for modification." In React: Use composition (children, render props, slots) so you extend behavior without editing the original component.

Bad: Modifying the component for every new case ​

tsx
function Alert({ type }: { type: 'success' | 'error' | 'warning' | 'info' }) {
  // Every new alert type = edit this component
  if (type === 'success') return <div className="green">...</div>;
  if (type === 'error') return <div className="red">...</div>;
  if (type === 'warning') return <div className="yellow">...</div>;
  // New type? Must edit this file.
}

Good: Compose with children/slots ​

tsx
// Base component is closed for modification
interface AlertProps {
  variant: 'success' | 'error' | 'warning' | 'info';
  children: React.ReactNode;
  icon?: React.ReactNode;
  action?: React.ReactNode;
}

function Alert({ variant, children, icon, action }: AlertProps) {
  return (
    <div className={`alert alert-${variant}`}>
      {icon && <span className="alert-icon">{icon}</span>}
      <div className="alert-content">{children}</div>
      {action && <div className="alert-action">{action}</div>}
    </div>
  );
}

// Extended without touching Alert source
<Alert variant="error" icon={<WarningIcon />} action={<RetryButton />}>
  Payment failed. Please try again.
</Alert>

Pattern: Compound Components (great for SDKs like checkout) ​

tsx
// Checkout SDK — merchants extend without modifying our code
<Checkout onComplete={handleComplete}>
  <Checkout.Header title="Pay Now" />
  <Checkout.PaymentMethods methods={['card', 'upi']} />
  <Checkout.Summary />
  <Checkout.SubmitButton label="Complete Payment" />
</Checkout>

L — Liskov Substitution Principle ​

"Components sharing an interface should be interchangeable." If two components accept the same props, swapping one for the other should work.

Example: Payment methods ​

tsx
// Shared interface — the contract
interface PaymentMethodProps {
  amount: number;
  currency: string;
  onSuccess: (result: PaymentResult) => void;
  onError: (error: PaymentError) => void;
}

// All implementations honor the same contract
function CardPayment({ amount, currency, onSuccess, onError }: PaymentMethodProps) {
  // Card-specific UI and logic
}

function UPIPayment({ amount, currency, onSuccess, onError }: PaymentMethodProps) {
  // UPI-specific UI and logic
}

function WalletPayment({ amount, currency, onSuccess, onError }: PaymentMethodProps) {
  // Wallet-specific UI and logic
}

// Parent doesn't care which one — they're interchangeable
function PaymentStep({ method, ...props }: { method: string } & PaymentMethodProps) {
  const Component = paymentComponents[method]; // looks up the right one
  return <Component {...props} />;
}

Why it matters ​

  • Adding a new payment method = add a new component, register it in the map
  • Zero changes to the parent
  • Each component is testable in isolation

I — Interface Segregation Principle ​

"Don't force a component to depend on props it doesn't use."

Bad: God prop object ​

tsx
interface Props {
  user: User;           // has 20 fields
  transactions: Transaction[];
  settings: Settings;
  onUpdateUser: (data: Partial<User>) => void;
  onDeleteTransaction: (id: string) => void;
}

// UserAvatar only needs name and image, but receives the entire User
function UserAvatar({ user }: Props) {
  return <img src={user.avatarUrl} alt={user.name} />;
}

Good: Narrow interfaces ​

tsx
// UserAvatar gets only what it needs
interface UserAvatarProps {
  name: string;
  avatarUrl: string;
  size?: 'sm' | 'md' | 'lg';
}

function UserAvatar({ name, avatarUrl, size = 'md' }: UserAvatarProps) {
  return <img src={avatarUrl} alt={name} className={`avatar-${size}`} />;
}

// Caller picks what to pass
<UserAvatar name={user.name} avatarUrl={user.avatarUrl} />

Practical rule ​

  • If you pass an entire object but only use 1-2 fields → pass just those fields
  • Exception: If the component genuinely operates on the whole object (like an <EditUserForm>)

D — Dependency Inversion Principle ​

"Depend on abstractions, not concrete implementations." In React: Inject dependencies (API clients, services) via props, context, or hooks instead of importing them directly.

Bad: Hard-coded dependency ​

tsx
import { stripeApi } from '../services/stripe'; // tightly coupled

function PaymentForm() {
  const handleSubmit = async (data: PaymentData) => {
    await stripeApi.charge(data); // can't test without Stripe
  };
  return <form onSubmit={handleSubmit}>...</form>;
}

Good: Inject the dependency ​

tsx
// Define the contract (abstraction)
interface PaymentService {
  charge: (data: PaymentData) => Promise<PaymentResult>;
  validate: (data: PaymentData) => ValidationResult;
}

// Component depends on the abstraction
interface PaymentFormProps {
  paymentService: PaymentService;
}

function PaymentForm({ paymentService }: PaymentFormProps) {
  const handleSubmit = async (data: PaymentData) => {
    const validation = paymentService.validate(data);
    if (!validation.isValid) return;
    await paymentService.charge(data);
  };
  return <form onSubmit={handleSubmit}>...</form>;
}

// In production: pass real service
<PaymentForm paymentService={stripeService} />

// In tests: pass mock
<PaymentForm paymentService={mockPaymentService} />

Context as DI container ​

tsx
// Provide services via context — components never import directly
const ServicesContext = createContext<Services | null>(null);

function useServices() {
  const services = useContext(ServicesContext);
  if (!services) throw new Error('Missing ServicesProvider');
  return services;
}

// Wrap app
<ServicesProvider services={{ payment: stripeService, analytics: mixpanelService }}>
  <App />
</ServicesProvider>

Cheat Sheet ​

PrincipleReact TranslationSmell
Single ResponsibilityOne component = one jobComponent > 200 lines, multiple effects
Open/ClosedComposition via children/slotsAdding if branches for new variants
Liskov SubstitutionShared interface = swappableComponents with same role but different APIs
Interface SegregationNarrow propsPassing entire objects for 1-2 fields
Dependency InversionInject services via props/contextDirect imports of API clients in components

Frontend interview preparation reference.