On this page · sections
Reusable Component Architecture
Three patterns share reusable logic with different ergonomics: custom hooks extract logic into composable functions, render props invert UI control, and headless components own behavior while consumers own presentation. Hooks became the default for app code; render props and headless APIs remain essential in libraries.
| Pattern | Tree depth | Ref pain | Best for |
|---|---|---|---|
| Custom hook | Low | Low | App logic reuse |
| Render prop | Medium | Low | Libraries, row injection |
| HOC | Medium | High | Legacy, class components |
| Headless | Low–medium | Low | Design systems, a11y primitives |
Custom Hooks
Definition
Extract shared logic into a composable function. Rules of Hooks: only call at top level, only in React functions.
'use client';
import { useEffect, useState } from 'react';
type Viewport = { width: number; height: number };
export function useViewport(): Viewport {
const [viewport, setViewport] = useState<Viewport>(() => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
}));
useEffect(() => {
function update() {
setViewport({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return viewport;
}
function ResponsiveBanner() {
const { width } = useViewport();
return <header>{width < 640 ? 'Mobile' : 'Desktop'} header</header>;
}Hooks share logic, not implementation — each call site has independent state unless backed by Context. A hook returning JSX is an anti-pattern; return data and callbacks.
Interview: Hooks do not replace Context —
useTheme()reads context. Test hooks withrenderHookfrom Testing Library.
Render Props
Definition
Parent owns state and effects; consumer owns UI via a function child or named render prop.
'use client';
import { useEffect, useState, type ReactNode } from 'react';
type Viewport = { width: number; height: number };
function ViewportProvider({
children,
}: {
children: (viewport: Viewport) => ReactNode;
}) {
const [viewport, setViewport] = useState<Viewport>({ width: 0, height: 0 });
useEffect(() => {
function update() {
setViewport({ width: window.innerWidth, height: window.innerHeight });
}
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
return <>{children(viewport)}</>;
}
function ResponsiveBanner() {
return (
<ViewportProvider>
{(vp) => (
<header>{vp.width < 640 ? 'Mobile' : 'Desktop'} header</header>
)}
</ViewportProvider>
);
}Drawback: callback nesting (“wrapper hell”). Render props are not deprecated — virtualizers and headless libraries still expose children(state) for full UI control.
Interview: Render prop vs compound components — compound shares state via Context between named children; render props pass state through a function call.
Higher-Order Components
Definition
Signature: (Wrapped) => EnhancedComponent. Famous pitfalls: ref forwarding, displayName, lost statics, props namespace collision.
'use client';
import { ComponentType } from 'react';
type WithLoadingProps = { isLoading: boolean };
function withLoading<P extends object>(Wrapped: ComponentType<P>) {
function WithLoadingComponent(props: P & WithLoadingProps) {
const { isLoading, ...rest } = props;
if (isLoading) return <p>Loading…</p>;
return <Wrapped {...(rest as P)} />;
}
WithLoadingComponent.displayName = `withLoading(${Wrapped.displayName ?? Wrapped.name})`;
return WithLoadingComponent;
}connect() from Redux is the classic HOC — useSelector / useDispatch replaced it. HOCs need forwardRef wrapper or focus management breaks. Prefer hooks for new app code.
Migration: Render Prop to Hook
Definition
'use client';
import { createContext, useContext, type ReactNode } from 'react';
const UserContext = createContext<{ id: string } | null>(null);
function UserProvider({ children }: { children: ReactNode }) {
return <UserContext.Provider value={{ id: '1' }}>{children}</UserContext.Provider>;
}
function useUser() {
const ctx = useContext(UserContext);
if (!ctx) throw new Error('useUser requires UserProvider');
return ctx;
}
function ModernPage() {
const user = useUser();
return <p>{user.id}</p>;
}Migration steps: identify injected shape → extract useX hook → optional Provider → update call sites → deprecate legacy export. Incremental per touched file — not big-bang delete.
Headless Components
Definition
Headless UI libraries render accessible DOM and own behavior — state, keyboard, ARIA, focus — but make no visual decisions. The consumer supplies every class name.
'use client';
import * as Dialog from '@radix-ui/react-dialog';
function ConfirmDialog({
open,
onOpenChange,
title,
body,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
body: string;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content className="content">
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>{body}</Dialog.Description>
<Dialog.Close className="btn">OK</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Headless is unstyled, not no DOM. Radix exposes [data-state='open'] for CSS hooks.
Interview: Headless does not always mean smaller bundle — verify with a bundle analyzer; tree-shake per primitive.
Behavior Hooks and Prop Getters
Definition
The hook returns state and prop getters; the component spreads them onto styled elements.
'use client';
import { useCallback, useId, useState } from 'react';
function useDisclosure(defaultOpen = false) {
const [open, setOpen] = useState(defaultOpen);
const id = useId();
const toggle = useCallback(() => setOpen((v) => !v), []);
const close = useCallback(() => setOpen(false), []);
const getTriggerProps = () => ({
'aria-expanded': open,
'aria-controls': `${id}-panel`,
onClick: toggle,
});
const getPanelProps = () => ({
id: `${id}-panel`,
hidden: !open,
role: 'region' as const,
'data-state': open ? 'open' : 'closed',
});
return { open, close, getTriggerProps, getPanelProps };
}Compose consumer handlers inside getters — spreading {...getTriggerProps()} onClick={trackClick} overwrites the library handler.
Real-world example
Design system layering:
design tokens
↓
headless primitive (vendor) — only imported in packages/ui
↓
styled component (@acme/ui)
↓
product feature — never imports vendor directly'use client';
import * as RadixDialog from '@radix-ui/react-dialog';
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
export const DialogContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<typeof RadixDialog.Content>
>(function DialogContent({ className, ...props }, ref) {
return (
<RadixDialog.Portal>
<RadixDialog.Overlay className="ds-dialog-overlay" />
<RadixDialog.Content ref={ref} className={`ds-dialog-content ${className ?? ''}`} {...props} />
</RadixDialog.Portal>
);
});When Hooks Are Not Enough
Definition
Render props still win when consumers need full UI ownership with your state machine:
<VirtualList itemCount={ids.length} rowHeight={48}>
{({ index, style }) => (
<div style={style}>Product {ids[index]}</div>
)}
</VirtualList>Ship useCombobox() → app teams, flat JSX (recommended)
Ship <ComboboxInput /> → compound UI
Optional children(index) → escape hatch for custom rows| Library | API style | Main strength |
|---|---|---|
| Radix UI | Compound parts, data-state, asChild | shadcn ecosystem, Tailwind design systems |
| React Aria | Behavior hooks + RAC | i18n, mobile/pointer heuristics |
| Ariakit | Store-driven composition | Flexible markup, strong defaults |
Pick one vendor per design system — mixing focus traps in one dialog produces keyboard traps.
Interview: Public combobox/select → use Downshift or React Aria, don’t roll your own. Server Components cannot use hooks.
Senior Interview Summary
What to say
Custom hooks are the default for app logic — flat tree, composable, testable with renderHook. Render props and HOCs remain valid in libraries for row injection and legacy class enhancement. Headless libraries own behavior and accessibility; consumers own every visual decision. Encapsulate the vendor behind one wrapper package.
Best mental model
Hook → share logic; component renders JSX
Render prop → share logic + UI slot via function
HOC → enhance component at create time; ref/displayName pain
Headless → state + getXProps(); presentation spreads propsPractical rule
App feature logic reuse → custom hook
Virtual list row markup → render prop or dual API
Class component enhancement → HOC or render prop
Public combobox/select → Downshift or React Aria
Design system → one vendor, styled wrapper, keyboard e2e in CI