React: compound components vs render props vs custom hooks
By Adam Hultman · 11 min read

As React apps grow, you start running into the same problem over and over again:
You have some useful logic.
Now where should it live?
At first, passing a few props around feels fine. Then the component grows. Then the props multiply. Then every tiny change requires touching five files and whispering apologies to your future self.
That is usually when React patterns start to matter.
Three patterns come up a lot:
- Compound components
- Render props
- Custom hooks
They all help you reuse logic or behaviour, but they solve slightly different problems. The trick is not memorizing the pattern. The trick is knowing when each one makes your code simpler, and when it is just fancy furniture in a small room.
Let’s break them down.
The Short Version
Use custom hooks when you want to reuse logic.
Use compound components when you want a set of components to work together with a clean, flexible API.
Use render props when a component owns some behaviour, but the caller needs full control over what gets rendered.
That’s the core idea.
Now let’s make it useful.
Custom Hooks: Best for Reusable Logic
Custom hooks are usually the first pattern I reach for.
They are great when you want to reuse stateful logic without forcing a specific UI structure.
For example, here is a simple hook for managing form values:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState } from 'react';
export function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
function handleChange(event) {
const { name, value } = event.target;
setValues((currentValues) => ({
...currentValues,
[name]: value,
}));
}
function reset() {
setValues(initialValues);
}
return {
values,
handleChange,
reset,
};
}You can use it in a component like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useForm } from './useForm';
function LoginForm() {
const { values, handleChange, reset } = useForm({
email: '',
password: '',
});
return (
<form>
<input
name="email"
value={values.email}
onChange={handleChange}
placeholder="Email"
/>
<input
name="password"
type="password"
value={values.password}
onChange={handleChange}
placeholder="Password"
/>
<button type="button" onClick={reset}>
Reset
</button>
</form>
);
}This keeps the component focused on rendering the form. The hook owns the reusable behavior.
When Custom Hooks Are a Good Fit
Custom hooks are a good fit when you are sharing things like:
- Form state
- Fetching logic
- Local storage syncing
- Feature flags
- Media queries
- Keyboard shortcuts
- Subscriptions
- Toggle state
- Permissions
- Reusable business logic
A hook is especially nice when the logic can exist without caring about the exact markup.
For example, useForm should not care whether the input is inside a modal, card, sidebar, or cursed enterprise settings page with 47 tabs.
It just manages form state.
Beautiful. Peaceful. Rare.
When Custom Hooks Become a Problem
Custom hooks can become a dumping ground if you are not careful.
A hook that starts like this is probably fine:
1
const { values, handleChange } = useForm(initialValues);A hook that ends up like this may be doing too much:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const {
values,
errors,
touched,
isDirty,
isSaving,
permissions,
layout,
theme,
modalState,
handleChange,
handleSubmit,
handleCancel,
handleMagicBusinessThing,
} = useMegaFormWorkflowThing();At that point, the hook is not simplifying the component. It is hiding an entire basement.
Custom hooks are great, but they should still have a clear job.
Compound Components: Best for Flexible Component APIs
Compound components are useful when you have a group of components that are meant to work together.
Think of components like:
- Tabs
- Accordions
- Dropdown menus
- Modals
- Select menus
- Steppers
- Radio groups
These components usually have shared state, but the consumer still needs control over layout.
A tabs component is a classic example.
You want the API to look like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="account">
Account settings go here.
</Tabs.Panel>
<Tabs.Panel value="billing">
Billing settings go here.
</Tabs.Panel>
</Tabs>That usage is nice because the structure is readable. The pieces clearly belong together, but the caller still controls the layout.
Here is a simplified version:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import {
createContext,
useContext,
useState,
} from 'react';
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [activeValue, setActiveValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ activeValue, setActiveValue }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ children }) {
return <div role="tablist">{children}</div>;
}
function TabsTrigger({ value, children }) {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs.Trigger must be used inside Tabs');
}
const { activeValue, setActiveValue } = context;
const isActive = activeValue === value;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActiveValue(value)}
>
{children}
</button>
);
}
function TabsPanel({ value, children }) {
const context = useContext(TabsContext);
if (!context) {
throw new Error('Tabs.Panel must be used inside Tabs');
}
if (context.activeValue !== value) {
return null;
}
return <div role="tabpanel">{children}</div>;
}
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;
export default Tabs;The parent owns the shared state. The child components read from context. The consumer gets a clean API.
That is the magic of compound components.
When Compound Components Are a Good Fit
Compound components are a good fit when:
- Components are clearly part of the same system
- Child components need shared state
- The caller needs layout flexibility
- Passing props manually would get annoying
- You want the usage to read naturally in JSX
The big win is ergonomics.
Instead of this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Tabs
tabs={[
{
label: 'Account',
value: 'account',
content: <AccountSettings />,
},
{
label: 'Billing',
value: 'billing',
content: <BillingSettings />,
},
]}
/>You can write this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="account">
<AccountSettings />
</Tabs.Panel>
<Tabs.Panel value="billing">
<BillingSettings />
</Tabs.Panel>
</Tabs>The second version gives the caller more control. That matters when real product requirements show up and immediately ruin your clean little config object.
When Compound Components Become a Problem
Compound components can become annoying when too much invisible context is involved.
If every child component depends on shared context, it can become harder to understand where data is coming from. You also need to watch out for unnecessary re-renders when context values change.
A few practical tips:
- Keep the context value small
- Split context if different values update at different times
- Throw helpful errors when child components are used incorrectly
- Do not make every component compound just because it feels fancy
- Keep the public API obvious
Compound components are great for component libraries and reusable UI systems. They can be overkill for a one-off component used in one place.
Not every div needs a family tree.
Render Props: Best When the Caller Controls the UI
Render props used to be much more common before hooks.
They are still useful sometimes, but I reach for them less often now.
A render prop is basically a function that returns UI. The component owns some logic, then lets the caller decide what to render with that logic.
Here is a simple hover example:
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState } from 'react';
function Hover({ children }) {
const [isHovered, setIsHovered] = useState(false);
return children({
isHovered,
bind: {
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
},
});
}Usage:
1
2
3
4
5
6
7
<Hover>
{({ isHovered, bind }) => (
<div {...bind}>
{isHovered ? 'Hovering!' : 'Not hovering'}
</div>
)}
</Hover>This works. The Hover component manages the behavior, and the caller controls the markup.
But today, I would usually write that as a hook:
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState } from 'react';
function useHover() {
const [isHovered, setIsHovered] = useState(false);
return {
isHovered,
bind: {
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
},
};
}Then use it like this:
1
2
3
4
5
6
7
8
9
function HoverCard() {
const { isHovered, bind } = useHover();
return (
<div {...bind}>
{isHovered ? 'Hovering!' : 'Not hovering'}
</div>
);
}That is usually simpler.
When Render Props Are Still Useful
Render props are still useful when a component needs to own behavior, but the caller needs full rendering control.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
<DataLoader url="/api/user">
{({ data, isLoading, error }) => {
if (isLoading) {
return <Spinner />;
}
if (error) {
return <ErrorMessage error={error} />;
}
return <UserProfile user={data} />;
}}
</DataLoader>This can be nice when the wrapper component is responsible for orchestration, but the caller owns all display decisions.
Render props can also be useful in library code where you want to support very flexible rendering without forcing a specific component structure.
When Render Props Become a Problem
Render props can get messy when they nest.
This is where things get sad:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<Auth>
{({ user }) => (
<Permissions user={user}>
{({ canEdit }) => (
<FeatureFlags>
{({ flags }) => (
<Dashboard
user={user}
canEdit={canEdit}
flags={flags}
/>
)}
</FeatureFlags>
)}
</Permissions>
)}
</Auth>Technically it works.
Emotionally, no.
This is the kind of thing hooks were very good at cleaning up:
1
2
3
4
5
6
7
8
9
10
11
12
13
function DashboardPage() {
const user = useUser();
const permissions = usePermissions(user);
const flags = useFeatureFlags();
return (
<Dashboard
user={user}
canEdit={permissions.canEdit}
flags={flags}
/>
);
}Much better. Less nesting. Fewer parentheses plotting against you.
How to Choose the Right Pattern
Here is the practical decision tree.
Start With Plain Props
Before reaching for a pattern, ask if plain props are enough.
1
2
3
<Button variant="primary" disabled={isSaving}>
Save
</Button>This is good. Do not abstract it into the shadow realm.
Use the simplest thing that makes the code clear.
Use a Custom Hook When You Want to Reuse Logic
Choose a custom hook when the main thing you want to reuse is behavior.
Good examples:
1
2
3
4
5
const form = useForm(initialValues);
const user = useCurrentUser();
const isOnline = useOnlineStatus();
const permissions = usePermissions();
const debouncedValue = useDebouncedValue(value);The hook should not care much about the markup.
Use Compound Components When You Want a Flexible UI API
Choose compound components when several components work together as one system.
Good examples:
1
2
3
4
5
6
7
8
9
<Tabs>
<Tabs.List>
<Tabs.Trigger value="one">One</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="one">
Content
</Tabs.Panel>
</Tabs>This is especially useful when building reusable UI components.
Use Render Props When the Caller Needs Full Control
Choose render props when the component owns some behavior, but the caller needs to decide exactly what gets rendered.
Good examples:
1
2
3
4
5
<FeatureGate feature="billing">
{({ enabled }) => (
enabled ? <BillingPage /> : <UpgradePrompt />
)}
</FeatureGate>This can still be a good pattern. It is just not always the first one I would reach for anymore.
Refactoring Between Patterns
The nice thing is that these patterns are not permanent life decisions.
You can refactor as the component grows.
From Custom Hook to Compound Component
This often happens when a hook is being used to wire together the same UI pieces over and over again.
You might start with:
1
const tabs = useTabs({ defaultValue: 'account' });Then eventually realize every usage has the same triggers, panels, active state, and accessibility concerns.
That may be a sign to build:
1
2
3
4
5
6
7
8
9
<Tabs defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="account">
Account settings
</Tabs.Panel>
</Tabs>The hook still might exist internally, but the public API becomes a component system.
From Compound Component to Custom Hook
Sometimes the opposite happens.
You build a compound component, then realize the useful part is not the UI. It is the behavior.
For example, maybe your dropdown logic is also needed in a command menu, context menu, and custom mobile sheet.
That might be a sign to extract:
1
const dropdown = useDropdown();Then the UI components can consume the hook internally, or advanced consumers can use the hook directly.
From Render Prop to Custom Hook
This is probably the most common refactor.
If your render prop component mainly exists to share logic, a hook may be cleaner.
Before:
1
2
3
4
5
6
7
<MousePosition>
{({ x, y }) => (
<p>
{x}, {y}
</p>
)}
</MousePosition>After:
1
2
3
4
5
6
7
8
9
function MousePositionLabel() {
const { x, y } = useMousePosition();
return (
<p>
{x}, {y}
</p>
);
}Less nesting. Same behavior. Happier eyeballs.
Performance Notes
Most performance problems with these patterns are not caused by the pattern itself. They usually come from how state and references are managed.
For compound components, context updates can re-render consumers. Keep context values focused, and split context when needed.
For render props, remember that inline functions create new references on each render. That is not automatically a disaster, but it can matter in performance-sensitive trees.
For custom hooks, watch state updates and effects. A hook can hide expensive work just as easily as a component can.
The best performance advice is boring but reliable: measure first, optimize the part that is actually slow, and do not turn every component into a memoized escape room unless you have evidence.
Final Thoughts
React patterns are tools, not personality traits.
Custom hooks are great for reusable logic.
Compound components are great for flexible component APIs.
Render props are useful when the caller needs full control over rendering, though hooks have replaced many of their older use cases.
The best pattern is the one that makes the next change easier.
That is the real test. Not whether the code looks clever. Not whether it uses the fanciest abstraction. Just this:
When the product changes next week, does this code give you somewhere obvious to put the change?
If yes, good pattern.
If no, congratulations, you have built a tiny framework and are now its unpaid maintainer.
Keep reading
Building real-time applications with React and WebSockets
Oct 18, 2024
Top mistakes to avoid when deploying react apps to production
Oct 18, 2024
How to build a scalable React app with Next.js and TypeScript
Oct 18, 2024