Patterns interview questions
Higher-Order Component (HOC)
A HOC is a function that takes a component and returns a new enhanced component. It is a pattern for reusing component logic.
tsfunction withSomething(WrappedComponent) { return function EnhancedComponent(props) { // add logic, inject props return <WrappedComponent {...props} extra="value" />; }; }Real example - withAuth
tsxfunction withAuth<T extends object>(WrappedComponent: React.ComponentType<T>) { return function AuthGuard(props: T) { const { user, isLoading } = useAuth(); if (isLoading) return <Spinner />; if (!user) return <Navigate to="/login" />; return <WrappedComponent {...props} />; }; } const ProtectedDashboard = withAuth(Dashboard);withLogger - debugging HOC
tsxfunction withLogger<T extends object>(WrappedComponent: React.ComponentType<T>) { return function LoggedComponent(props: T) { useEffect(() => { console.log(`${WrappedComponent.displayName} mounted`, props); }); return <WrappedComponent {...props} />; }; }Composing multiple HOCs
tsxconst EnhancedPage = withAuth(withLogger(withErrorBoundary(Page))); // reads right to left - hard to follow // compose utility helps import { compose } from 'redux'; const EnhancedPage = compose(withAuth, withLogger, withErrorBoundary)(Page);HOC vs custom hooks
HOC Custom Hook Reuses JSX + logic Logic only Adds to tree Yes - wrapper node No Debugging Wrapper hell Clean Modern usage Rare Preferred HOCs were the primary pattern before hooks. Today custom hooks replace most use cases. HOCs are still useful for wrapping third-party components or adding JSX-level behavior like error boundaries.
updated
did you know it?Render Props
The render props pattern shares logic between components by passing a function as a prop. The function receives data and returns JSX.
Basic example
tsxinterface MousePosition { x: number; y: number; } function MouseTracker({ render }: { render: (pos: MousePosition) => React.ReactNode }) { const [pos, setPos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}> {render(pos)} </div> ); } // usage - full control over what gets rendered <MouseTracker render={({ x, y }) => ( <p>Mouse is at {x}, {y}</p> )} />children as a function (most common variant)
tsxfunction Toggle({ children }: { children: (on: boolean, toggle: () => void) => React.ReactNode }) { const [on, setOn] = useState(false); return <>{children(on, () => setOn(v => !v))}</>; } // usage <Toggle> {(on, toggle) => ( <div> <button onClick={toggle}>{on ? 'Hide' : 'Show'}</button> {on && <Modal />} </div> )} </Toggle>Replaced by custom hooks
tsx// the same MouseTracker logic as a hook - much cleaner function useMousePosition() { const [pos, setPos] = useState({ x: 0, y: 0 }); useEffect(() => { const handler = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); }, []); return pos; } function Component() { const { x, y } = useMousePosition(); return <p>Mouse at {x}, {y}</p>; }Render props solved logic sharing before hooks, but introduced deeply nested JSX. Custom hooks replaced most render prop patterns. You'll still encounter them as "children as a function" in libraries like Formik, Downshift and Headless UI (
{({ open }) => ...}).updated
did you know it?Compound Components
The compound components pattern creates a group of components that work together and share implicit state. It gives the consumer flexible composition control.
Classic example - Select / Tabs
tsx// usage - clean, expressive API <Tabs defaultValue="profile"> <Tabs.List> <Tabs.Trigger value="profile">Profile</Tabs.Trigger> <Tabs.Trigger value="settings">Settings</Tabs.Trigger> </Tabs.List> <Tabs.Panel value="profile"><ProfileForm /></Tabs.Panel> <Tabs.Panel value="settings"><SettingsForm /></Tabs.Panel> </Tabs>Implementation with Context
tsxinterface TabsContext { active: string; setActive: (v: string) => void; } const TabsCtx = createContext<TabsContext | null>(null); const useTabs = () => useContext(TabsCtx)!; function Tabs({ defaultValue, children }: { defaultValue: string; children: React.ReactNode }) { const [active, setActive] = useState(defaultValue); return ( <TabsCtx.Provider value={{ active, setActive }}> <div>{children}</div> </TabsCtx.Provider> ); } function TabsList({ children }: { children: React.ReactNode }) { return <div role="tablist">{children}</div>; } function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) { const { active, setActive } = useTabs(); return ( <button role="tab" aria-selected={active === value} onClick={() => setActive(value)} > {children} </button> ); } function TabsPanel({ value, children }: { value: string; children: React.ReactNode }) { const { active } = useTabs(); return active === value ? <div role="tabpanel">{children}</div> : null; } // attach as static properties Tabs.List = TabsList; Tabs.Trigger = TabsTrigger; Tabs.Panel = TabsPanel;Why it's better than prop drilling
tsx// without compound components - prop soup <Tabs items={['Profile', 'Settings']} panels={[<ProfileForm />, <SettingsForm />]} defaultIndex={0} onTabClick={...} />This pattern is used by Radix UI, Headless UI, Ark UI and shadcn/ui. Learning it is essential for building flexible design system components.
updated
did you know it?Container / Presentational Pattern
This pattern separates what to render (presentational) from how to get data (container). Also known as smart/dumb or stateful/stateless components.
Presentational component - pure UI
tsx// only props, no data fetching, no business logic interface UserCardProps { name: string; email: string; avatar: string; onFollow: () => void; } function UserCard({ name, email, avatar, onFollow }: UserCardProps) { return ( <div className="card"> <img src={avatar} alt={name} /> <h3>{name}</h3> <p>{email}</p> <button onClick={onFollow}>Follow</button> </div> ); }Container component - data and logic
tsxfunction UserCardContainer({ userId }: { userId: string }) { const { data: user, isLoading } = useQuery({ queryKey: ['user', userId], queryFn: () => api.getUser(userId), }); const followMutation = useMutation({ mutationFn: () => api.followUser(userId), }); if (isLoading) return <Skeleton />; return ( <UserCard name={user.name} email={user.email} avatar={user.avatar} onFollow={() => followMutation.mutate()} /> ); }Benefits
UserCardis easy to test - pure function of props.UserCardis easy to reuse with different data sources.UserCardis easy to work on in Storybook.- Logic in
UserCardContaineris isolated and replaceable.
Modern take
With hooks this separation is less strict - logic can live in custom hooks inside the same component. But the principle of separating UI from data concerns remains valid.
tsx// hooks replace the container component function UserCard({ userId }: { userId: string }) { const { user, follow } = useUserCard(userId); // all logic in hook return <UserCardUI user={user} onFollow={follow} />; }This pattern is less about file structure and more about keeping UI components free of side effects. A
UserCardthat knows nothing about APIs is far easier to test and maintain.updated
did you know it?Observer Pattern
The observer pattern defines a one-to-many relationship: when one object (subject) changes state, all its dependents (observers) are notified automatically.
Core implementation
tsclass EventEmitter<T extends Record<string, unknown>> { private listeners: Partial<{ [K in keyof T]: ((data: T[K]) => void)[] }> = {}; on<K extends keyof T>(event: K, listener: (data: T[K]) => void) { if (!this.listeners[event]) this.listeners[event] = []; this.listeners[event]!.push(listener); return () => this.off(event, listener); // return unsubscribe } off<K extends keyof T>(event: K, listener: (data: T[K]) => void) { this.listeners[event] = this.listeners[event]?.filter(l => l !== listener); } emit<K extends keyof T>(event: K, data: T[K]) { this.listeners[event]?.forEach(l => l(data)); } } // usage type AppEvents = { 'user:login': { userId: string }; 'cart:updated': { count: number }; }; const emitter = new EventEmitter<AppEvents>(); const unsubscribe = emitter.on('user:login', ({ userId }) => { console.log('User logged in:', userId); }); emitter.emit('user:login', { userId: 'u1' }); unsubscribe(); // cleanupWhere you see it in frontend
- DOM events -
addEventListener/removeEventListener. - RxJS -
Observable.subscribe(). - Zustand -
store.subscribe(). - React Query - cache subscriptions.
- WebSocket -
socket.on('message', handler).
In React - custom event bus
tsx// cross-component communication without prop drilling or shared state const bus = new EventEmitter(); // component A - emit function ButtonA() { return <button onClick={() => bus.emit('refresh', {})}>Refresh</button>; } // component B - listen function TableB() { useEffect(() => { return bus.on('refresh', () => refetch()); }, []); }Observer is everywhere in frontend - the DOM event system itself is observer. The main risk is memory leaks from unremoved listeners - always clean up in
useEffectreturn.updated
did you know it?- DOM events -
Factory Pattern
A factory is a function or class that creates objects without exposing the creation logic. The caller asks for an object and receives one without knowing how it was built.
Function factory
tstype NotificationType = 'success' | 'error' | 'warning' | 'info'; interface Notification { id: string; type: NotificationType; message: string; icon: string; color: string; duration: number; } function createNotification(type: NotificationType, message: string): Notification { const base = { id: crypto.randomUUID(), message }; const config: Record<NotificationType, Omit<Notification, 'id' | 'message'>> = { success: { type: 'success', icon: '✓', color: '#22c55e', duration: 3000 }, error: { type: 'error', icon: '✕', color: '#ef4444', duration: 5000 }, warning: { type: 'warning', icon: '!', color: '#f59e0b', duration: 4000 }, info: { type: 'info', icon: 'i', color: '#3b82f6', duration: 3000 }, }; return { ...base, ...config[type] }; } // clean usage - no config knowledge needed const n = createNotification('error', 'Something went wrong');React component factory
tsxfunction createFormField(type: 'text' | 'email' | 'password' | 'tel') { return function FormField({ label, ...props }: FormFieldProps) { return ( <label> {label} <input type={type} {...props} /> </label> ); }; } const EmailField = createFormField('email'); const PasswordField = createFormField('password'); const PhoneField = createFormField('tel');API client factory
tsfunction createApiClient(baseUrl: string, token: string) { const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }; return { get: (path: string) => fetch(`${baseUrl}${path}`, { headers }), post: (path: string, body: unknown) => fetch(`${baseUrl}${path}`, { method: 'POST', headers, body: JSON.stringify(body) }), }; } const api = createApiClient('https://api.example.com', userToken); api.get('/users');Factory is one of the most natural patterns in JavaScript because functions are first-class. Any time you call a function to get back a configured object or component, you are using a factory.
updated
did you know it?Singleton Pattern
A singleton ensures a class or module has only one instance and provides a global access point to it.
Module-based singleton (most common in JS)
ts// logger.ts - module is evaluated once, instance is shared class Logger { private logs: string[] = []; log(message: string) { const entry = `[${new Date().toISOString()}] ${message}`; this.logs.push(entry); console.log(entry); } getLogs() { return [...this.logs]; } } export const logger = new Logger(); // one instance, shared across importsts// anywhere in the app import { logger } from './logger'; logger.log('User signed in');Class-based singleton
tsclass Store { private static instance: Store | null = null; private state = {}; private constructor() {} // prevent direct instantiation static getInstance(): Store { if (!Store.instance) { Store.instance = new Store(); } return Store.instance; } } const store = Store.getInstance();Common singletons in frontend
ts// query client - one instance per app const queryClient = new QueryClient(); // axios instance - shared config const http = axios.create({ baseURL: '/api', timeout: 10000 }); // zustand store - one store const useStore = create((set) => ({ ... }));Trade-offs
Pro Con Global access point Hidden dependencies Shared state/config Hard to test (shared state bleeds between tests) Resource efficient Tight coupling Simple to use Difficult to mock In testing, singletons cause the most pain - one test's state can affect another. The fix is to reset state between tests or provide a
reset()method. ES modules make accidental singletons very easy in JavaScript.updated
did you know it?Strategy Pattern
The strategy pattern defines a family of algorithms, puts each in a separate function/class, and makes them interchangeable. The context uses one strategy at a time without knowing the details.
Classic example - sorting
tstype SortStrategy<T> = (items: T[]) => T[]; const byName: SortStrategy<User> = (users) => [...users].sort((a, b) => a.name.localeCompare(b.name)); const byAge: SortStrategy<User> = (users) => [...users].sort((a, b) => a.age - b.age); const byCreatedAt: SortStrategy<User> = (users) => [...users].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); // context - doesn't know which strategy is used function UserList({ users, sortBy }: { users: User[]; sortBy: SortStrategy<User> }) { const sorted = sortBy(users); return sorted.map(u => <UserRow key={u.id} user={u} />); } <UserList users={users} sortBy={byName} /> <UserList users={users} sortBy={byAge} />Validation strategies
tstype Validator = (value: string) => string | null; // null = valid const required: Validator = (v) => v.trim() ? null : 'Required'; const email: Validator = (v) => /^[^@]+@[^@]+$/.test(v) ? null : 'Invalid email'; const minLength = (n: number): Validator => (v) => v.length >= n ? null : `Min ${n} characters`; function validate(value: string, validators: Validator[]): string[] { return validators.map(v => v(value)).filter(Boolean) as string[]; } validate(input, [required, email, minLength(5)]);Payment strategies
tsinterface PaymentStrategy { pay(amount: number): Promise<{ success: boolean; transactionId: string }>; } class StripeStrategy implements PaymentStrategy { async pay(amount: number) { return stripe.charge(amount); } } class PayPalStrategy implements PaymentStrategy { async pay(amount: number) { return paypal.transfer(amount); } } class Checkout { constructor(private strategy: PaymentStrategy) {} async process(amount: number) { return this.strategy.pay(amount); } } const checkout = new Checkout(new StripeStrategy());Strategy is one of the most useful patterns in everyday frontend code. Any time you have
if (type === 'x') ... else if (type === 'y') ...for behavior selection, you likely want a strategy map instead.updated
did you know it?Proxy Pattern
The proxy pattern provides a substitute that controls access to another object. It can intercept and customize operations like get, set, and delete.
JavaScript Proxy - native implementation
tsconst handler: ProxyHandler<Record<string, unknown>> = { get(target, key) { console.log(`Getting: ${String(key)}`); return Reflect.get(target, key); }, set(target, key, value) { console.log(`Setting: ${String(key)} = ${value}`); return Reflect.set(target, key, value); }, }; const obj = new Proxy({}, handler); obj.name = 'Alice'; // logs: Setting: name = Alice obj.name; // logs: Getting: nameValidation proxy
tsfunction createValidatedUser(data: Partial<User>) { return new Proxy(data, { set(target, key, value) { if (key === 'age' && (typeof value !== 'number' || value < 0)) { throw new Error('Age must be a positive number'); } if (key === 'email' && !value.includes('@')) { throw new Error('Invalid email'); } return Reflect.set(target, key, value); }, }); }Reactive state with Proxy (how Vue 3 works)
tsfunction reactive<T extends object>(target: T): T { return new Proxy(target, { get(obj, key) { track(obj, key); // record dependency return Reflect.get(obj, key); }, set(obj, key, value) { const result = Reflect.set(obj, key, value); trigger(obj, key); // notify subscribers return result; }, }); } const state = reactive({ count: 0 }); state.count++; // triggers re-render automaticallyAPI proxy for caching
tsconst cachedApi = new Proxy(api, { get(target, method: string) { return async (...args: unknown[]) => { const key = `${method}:${JSON.stringify(args)}`; if (cache.has(key)) return cache.get(key); const result = await target[method](...args); cache.set(key, result); return result; }; }, });Vue 3's entire reactivity system is built on
Proxy. Immer also uses proxies to allow seemingly mutable code that produces immutable updates. Understanding Proxy helps you understand how modern frameworks work under the hood.updated
did you know it?Module Pattern
The module pattern encapsulates related code into a self-contained unit with a public interface, hiding implementation details.
Classic IIFE module (pre-ES modules)
tsconst CartModule = (() => { // private - not accessible outside let items: CartItem[] = []; let discount = 0; function calculateTotal() { return items.reduce((sum, i) => sum + i.price, 0) * (1 - discount); } // public interface return { addItem(item: CartItem) { items = [...items, item]; }, removeItem(id: string) { items = items.filter(i => i.id !== id); }, setDiscount(d: number) { discount = d; }, getTotal() { return calculateTotal(); }, getItems() { return [...items]; }, // return copy - protect internal state }; })(); CartModule.addItem({ id: '1', price: 29.99 }); CartModule.getTotal(); // 29.99 // CartModule.items -> undefined - privateES Modules - native module system
ts// cart.ts let items: CartItem[] = []; // module-private export function addItem(item: CartItem) { items = [...items, item]; } export function getItems() { return [...items]; } export function getTotal() { return items.reduce((sum, i) => sum + i.price, 0); } // private - not exported, not accessible function applyTax(amount: number) { return amount * 1.2; }ts// anywhere import { addItem, getTotal } from './cart';Module patterns in practice
ts// barrel file - re-export public API // features/auth/index.ts export { useAuth } from './useAuth'; export { AuthProvider } from './AuthProvider'; export type { User, AuthState } from './types'; // internal hooks, utils, api calls stay privateKey benefits
- Encapsulation - hide implementation, expose only what is needed.
- Namespace - no global variable pollution.
- Reusability - import only what you need.
- Tree shaking - bundlers can remove unused exports.
ES modules are the native module pattern. A barrel file (
index.ts) defines the public API of a feature folder, but use it with care: large barrels slow down dev servers and tests and can hurt tree shaking, because importing one export loads the whole folder. Many teams keep barrels only at feature boundaries or import files directly.updated
did you know it?