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 ​
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 ​
// 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 ​
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 ​
// 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) ​
// 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 ​
// 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 ​
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 ​
// 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 ​
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 ​
// 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 ​
// 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 ​
| Principle | React Translation | Smell |
|---|---|---|
| Single Responsibility | One component = one job | Component > 200 lines, multiple effects |
| Open/Closed | Composition via children/slots | Adding if branches for new variants |
| Liskov Substitution | Shared interface = swappable | Components with same role but different APIs |
| Interface Segregation | Narrow props | Passing entire objects for 1-2 fields |
| Dependency Inversion | Inject services via props/context | Direct imports of API clients in components |