There are dozens of React component patterns documented across blogs, conference talks, and the official docs. In practice, I use about five of them regularly. The rest are either too clever, too situational, or solve problems that simpler approaches handle just as well.
These are the patterns that appear in my production code repeatedly, with real examples of where they help and where they fall apart.
Compound components
This is the pattern where a parent component shares implicit state with its children through Context. The canonical example is a disclosure component:
function Disclosure({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
return (
<DisclosureContext.Provider value={{ open, toggle: () => setOpen(!open) }}>
{children}
</DisclosureContext.Provider>
)
}
function DisclosureTrigger({ children }: { children: ReactNode }) {
const { toggle } = useDisclosureContext()
return <button onClick={toggle}>{children}</button>
}
function DisclosureContent({ children }: { children: ReactNode }) {
const { open } = useDisclosureContext()
if (!open) return null
return <div>{children}</div>
}
Usage looks like:
<Disclosure>
<DisclosureTrigger>Show details</DisclosureTrigger>
<DisclosureContent>
<p>The details are here.</p>
</DisclosureContent>
</Disclosure>
I use this for tabs, accordions, dropdown menus, and modal dialogs. The API reads clearly, the internal state is encapsulated, and the consumer can arrange the subcomponents however they want.
Where it falls apart: when the compound structure is too rigid. If a child component needs to be deeply nested (inside a layout wrapper, for instance), the Context connection breaks unless you're careful about the provider placement. For deeply nested cases, Zustand or a ref-based approach is simpler.
Render props (yes, still)
Hooks replaced most render prop patterns, but there's one case where I still use them: when a component needs to hand rendering control to its consumer without imposing a DOM structure.
interface VirtualListProps<T> {
items: T[]
height: number
rowHeight: number
renderItem: (item: T, index: number) => ReactNode
}
function VirtualList<T>({ items, height, rowHeight, renderItem }: VirtualListProps<T>) {
const [scrollTop, setScrollTop] = useState(0)
const startIndex = Math.floor(scrollTop / rowHeight)
const endIndex = Math.min(startIndex + Math.ceil(height / rowHeight) + 1, items.length)
const visibleItems = items.slice(startIndex, endIndex)
return (
<div style={{ height, overflow: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ position: 'absolute', top: (startIndex + i) * rowHeight, height: rowHeight, width: '100%' }}>
{renderItem(item, startIndex + i)}
</div>
))}
</div>
</div>
)
}
The renderItem prop lets the consumer decide what each row looks like. A hook can't do this because hooks can't return JSX conditionally based on a parent's render cycle.
Controlled and uncontrolled inputs
Every form input component I build supports both controlled and uncontrolled usage:
interface InputProps {
value?: string
defaultValue?: string
onChange?: (value: string) => void
}
function Input({ value, defaultValue, onChange }: InputProps) {
const [internal, setInternal] = useState(defaultValue ?? '')
const isControlled = value !== undefined
const currentValue = isControlled ? value : internal
function handleChange(e: ChangeEvent<HTMLInputElement>) {
if (!isControlled) setInternal(e.target.value)
onChange?.(e.target.value)
}
return <input value={currentValue} onChange={handleChange} />
}
Pass value and the component is controlled. Pass defaultValue (or nothing) and it manages its own state. This dual-mode pattern matches what native HTML inputs do, and it means the component works in both form-library-managed forms and quick one-off forms.
The container/presentational split
This pattern has been declared dead multiple times, usually by people who interpret it too rigidly. The idea isn't that you need separate files for "smart" and "dumb" components. The idea is that separating data fetching from rendering makes both easier to test and reuse.
// Fetches and manages data
function UserProfileContainer({ userId }: { userId: string }) {
const { data, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
if (isLoading) return <Skeleton />
if (!data) return null
return <UserProfile user={data} />
}
// Pure rendering, easy to test, easy to use in Storybook
function UserProfile({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
)
}
I don't enforce this as a rule. Sometimes a component is simple enough that splitting it adds indirection without benefit. But for components that are reused across multiple contexts, or that have complex rendering logic worth testing independently, the split's valuable.
Custom hooks for shared logic
This isn't a "pattern" so much as the natural outcome of hooks, but it's worth stating explicitly: if two components share non-trivial logic, extract it into a hook.
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false)
useEffect(() => {
const mql = window.matchMedia(query)
setMatches(mql.matches)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}
The criteria for extraction: if the logic involves useState or useEffect and appears in more than one component, it becomes a hook. If it's pure computation, it becomes a utility function.
What I don't use
Higher-order components. They were necessary before hooks. They aren't necessary now. Every HOC I've seen in the last three years could have been a hook with less code and better type inference.
Render props for data fetching. React Query and SWR handle this better. The render prop pattern for data fetching was a reasonable solution in 2018. It's unnecessary in 2022.
Provider pyramids. If your app has more than four nested providers at the root, the architecture needs rethinking. Each provider is a re-render boundary and a cognitive burden. Consolidate related state or use a store.
The best component patterns are the ones you don't notice. They make the code easier to read, test, and modify without drawing attention to the pattern itself. If someone reads your code and says "nice compound component pattern," the pattern is probably too prominent.
The compound component example for Disclosure is the cleanest implementation I've seen. We adopted this pattern for our design system and it made the component APIs much more readable. The note about it breaking with deep nesting is also important — we hit that exact issue.
Finally someone says HOCs aren't necessary anymore. I've been arguing this on my team for months. Hooks do everything HOCs do with better type inference and less indirection.
The controlled/uncontrolled dual-mode pattern is something I've been doing intuitively but never formalised. Seeing it written out with the isControlled check is helpful. Adding this to our component library template.