Topics
v5.0.0
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.

PatternTree depthRef painBest for
Custom hookLowLowApp logic reuse
Render propMediumLowLibraries, row injection
HOCMediumHighLegacy, class components
HeadlessLow–mediumLowDesign 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 with renderHook from 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
LibraryAPI styleMain strength
Radix UICompound parts, data-state, asChildshadcn ecosystem, Tailwind design systems
React AriaBehavior hooks + RACi18n, mobile/pointer heuristics
AriakitStore-driven compositionFlexible 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 props

Practical 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

Last updated on