PatternsArchitecture Patterns
Render Props vs Hooks
When to use render props, hooks, or both together
Render Props vs Hooks
Understanding when to use render props, custom hooks, or combine both is essential for writing clean React code.
🎯 The Evolution
// 2016: Render Props Era
<DataFetcher url="/api/users">
{({ data, loading }) => (
loading ? <Spinner /> : <UserList data={data} />
)}
</DataFetcher>
// 2019: Hooks Era
function UserList() {
const { data, loading } = useFetch('/api/users');
return loading ? <Spinner /> : <List data={data} />;
}
// 2024: Best of Both
function UserList() {
return (
<DataFetcher url="/api/users">
{({ data, loading }) => {
const processed = useProcessData(data);
return loading ? <Spinner /> : <List data={processed} />;
}}
</DataFetcher>
);
}📊 Quick Comparison
| Feature | Render Props | Hooks | Combined |
|---|---|---|---|
| Reusability | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Readability | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| JSX Control | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| Type Safety | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Bundle Size | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Learning Curve | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
🪝 Pattern 1: Hooks (Preferred for Logic)
Use hooks when you want to reuse logic, not JSX structure.
Basic Custom Hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setData(data);
setLoading(false);
}
})
.catch(error => {
if (!cancelled) {
setError(error);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
// Usage - Clean and simple!
function UserList() {
const { data, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{data?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Advanced Hook with Options
interface UseFetchOptions<T> {
initialData?: T;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
retry?: number;
enabled?: boolean;
}
function useFetch<T>(
url: string,
options: UseFetchOptions<T> = {}
) {
const {
initialData,
onSuccess,
onError,
retry = 0,
enabled = true
} = options;
const [data, setData] = useState<T | null>(initialData || null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<Error | null>(null);
const retryCount = useRef(0);
const fetchData = useCallback(async () => {
if (!enabled) return;
setLoading(true);
setError(null);
try {
const response = await fetch(url);
const result = await response.json();
setData(result);
onSuccess?.(result);
retryCount.current = 0;
} catch (err) {
const error = err as Error;
if (retryCount.current < retry) {
retryCount.current++;
setTimeout(fetchData, 1000 * retryCount.current);
} else {
setError(error);
onError?.(error);
}
} finally {
setLoading(false);
}
}, [url, enabled, retry, onSuccess, onError]);
useEffect(() => {
fetchData();
}, [fetchData]);
const refetch = useCallback(() => {
retryCount.current = 0;
fetchData();
}, [fetchData]);
return { data, loading, error, refetch };
}
// Usage with options
function UserProfile({ userId }: { userId: string }) {
const { data, loading, refetch } = useFetch<User>(
`/api/users/${userId}`,
{
onSuccess: (user) => {
console.log('User loaded:', user.name);
},
retry: 3,
enabled: !!userId
}
);
return (
<div>
{loading ? <Spinner /> : <Profile user={data} />}
<button onClick={refetch}>Refresh</button>
</div>
);
}🎨 Pattern 2: Render Props (Preferred for JSX Control)
Use render props when you want to control JSX structure from the parent.
Basic Render Prop
interface DataFetcherProps<T> {
url: string;
children: (state: {
data: T | null;
loading: boolean;
error: Error | null;
}) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return children({ data, loading, error });
}
// Usage - Full control over rendering!
<DataFetcher<User[]> url="/api/users">
{({ data, loading, error }) => {
if (loading) return <CustomSpinner />;
if (error) return <CustomError error={error} />;
return (
<div className="custom-layout">
<h1>Users ({data?.length})</h1>
<UserGrid users={data} />
</div>
);
}}
</DataFetcher>Function as Children (FaCC)
// Same as render props, just different syntax
<DataFetcher url="/api/users">
{(state) => <UserList {...state} />}
</DataFetcher>
// Also works with render prop
<DataFetcher
url="/api/users"
render={(state) => <UserList {...state} />}
/>With Slot Pattern
interface MouseTrackerProps {
children: (position: { x: number; y: number }) => React.ReactNode;
}
function MouseTracker({ children }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return children(position);
}
// Usage - Total control over what to render
<MouseTracker>
{({ x, y }) => (
<div style={{ position: 'fixed', left: x, top: y }}>
<Cursor />
</div>
)}
</MouseTracker>🔄 Pattern 3: Combined (Best of Both Worlds)
Combine hooks and render props for maximum flexibility.
Hook Inside Render Prop
function DataList() {
return (
<DataFetcher url="/api/items">
{({ data, loading }) => {
// Use hooks inside render prop!
const filtered = useFilter(data, filterTerm);
const sorted = useSorting(filtered, sortBy);
if (loading) return <Spinner />;
return <List items={sorted} />;
}}
</DataFetcher>
);
}Render Prop Component Using Hooks
function MouseTracker({ children }: MouseTrackerProps) {
// Component uses hooks internally
const position = useMousePosition();
const velocity = useMouseVelocity(position);
// But exposes render prop API
return children({ position, velocity });
}
// Best of both: hook implementation, render prop interface
<MouseTracker>
{({ position, velocity }) => (
<Cursor position={position} speed={velocity} />
)}
</MouseTracker>Headless Component Pattern
// Component provides logic via render props
function Dropdown({ children }: { children: (api: DropdownAPI) => React.ReactNode }) {
const [open, setOpen] = useState(false);
const ref = useClickOutside(() => setOpen(false));
const api = {
open,
setOpen,
toggle: () => setOpen(!open),
ref
};
return children(api);
}
// Usage with full styling control
<Dropdown>
{({ open, toggle, ref }) => (
<div ref={ref} className="my-dropdown">
<button onClick={toggle}>Menu</button>
{open && (
<div className="my-menu">
<MenuItem>Option 1</MenuItem>
<MenuItem>Option 2</MenuItem>
</div>
)}
</div>
)}
</Dropdown>🎯 When to Use What?
Use Hooks When:
// ✅ Reusing LOGIC only
const { data, loading } = useFetch(url);
const authenticated = useAuth();
const { width, height } = useWindowSize();
// ✅ Composing multiple hooks
function useUserData(userId: string) {
const { data: user } = useFetch(`/users/${userId}`);
const { data: posts } = useFetch(`/users/${userId}/posts`);
const permissions = usePermissions(user);
return { user, posts, permissions };
}
// ✅ Simple state management
const [count, setCount] = useState(0);
const theme = useTheme();Use Render Props When:
// ✅ User needs control over JSX structure
<DataFetcher url={url}>
{({ data }) => (
<CustomLayout>
<CustomHeader data={data} />
<CustomContent data={data} />
</CustomLayout>
)}
</DataFetcher>
// ✅ Multiple render slots
<SplitView>
{({ Left, Right }) => (
<>
<Left><Sidebar /></Left>
<Right><Content /></Right>
</>
)}
</SplitView>
// ✅ Animation libraries
<Motion>
{({ x, y, opacity }) => (
<div style={{ transform: `translate(${x}px, ${y}px)`, opacity }}>
Animated content
</div>
)}
</Motion>Use Both When:
// ✅ Library components (Radix, Headless UI)
<Tooltip>
{({ open }) => {
const animation = useAnimation(open);
return <TooltipContent style={animation} />;
}}
</Tooltip>
// ✅ Complex components with hooks inside
<Form onSubmit={handleSubmit}>
{({ register, errors }) => {
const validation = useValidation();
return <FormFields register={register} errors={errors} />;
}}
</Form>🏢 Real-World Examples
React Query (Hooks)
// Hooks-based API
const { data, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});Downshift (Render Props)
// Render props for full control
<Downshift>
{({ getInputProps, getItemProps, isOpen }) => (
<div>
<input {...getInputProps()} />
{isOpen && (
<ul>
{items.map((item, index) => (
<li {...getItemProps({ item, index })}>{item}</li>
))}
</ul>
)}
</div>
)}
</Downshift>Framer Motion (Both)
// Provides both hooks and render props
const controls = useAnimation(); // Hook
<motion.div animate={controls}> // Component
{/* Or render prop */}
</motion.div>📚 Key Takeaways
- Hooks for logic - Reuse stateful behavior
- Render props for structure - Control JSX layout
- Combine both - Maximum flexibility
- Hooks are simpler - Prefer when possible
- Render props for libraries - When users need control
- TypeScript works better with hooks
- Bundle size - Hooks are smaller
Modern rule of thumb: Start with hooks, add render props only when users need JSX control.