I've refactored the same piece of logic three different ways in my career:
2016: Higher-Order Components everywhere
export default withAuth(withTheme(withRouter(MyComponent)));
2018: Render Props for everything
<Auth>
{({ user }) => (
<Theme>
{({ theme }) => (
<Router>
{({ navigate }) => (
<MyComponent user={user} theme={theme} navigate={navigate} />
)}
</Router>
)}
</Theme>
)}
</Auth>
2020: Hooks have won
const user = useAuth();
const theme = useTheme();
const navigate = useNavigate();
return <MyComponent user={user} theme={theme} navigate={navigate} />;
Each pattern emerged to solve real problems. Each has trade-offs. And TypeScript changes the equation significantly.
Let me show you which pattern to use when, and how TypeScript makes (or breaks) each approach.
The Evolution: Why We Have Three Patterns
Higher-Order Components (2015-2018)
Problem they solved: Component logic reuse before hooks
Pattern: Wrap components to inject props
Peak usage: React 15-16 era
Render Props (2017-2019)
Problem they solved: HOC limitations (wrapper hell, prop naming collisions)
Pattern: Pass render functions as props
Peak usage: React 16 era
Hooks (2019-Present)
Problem they solved: Both HOC and Render Props limitations
Pattern: Compose logic with functions, not components
Peak usage: React 16.8+
Pattern 1: Higher-Order Components (HOCs)
Basic HOC with TypeScript
// withAuth.tsx
import { ComponentType } from 'react';
interface User {
id: string;
name: string;
email: string;
}
interface WithAuthProps {
user: User;
logout: () => void;
}
// ✅ Type-safe HOC
export function withAuth<P extends object>(
Component: ComponentType<P & WithAuthProps>
): ComponentType<P> {
return function AuthenticatedComponent(props: P) {
const user = useAuth();
const logout = useLogout();
if (!user) {
return <div>Please log in</div>;
}
return <Component {...props} user={user} logout={logout} />;
};
}
// Usage
interface DashboardProps {
title: string;
}
function Dashboard({ title, user, logout }: DashboardProps & WithAuthProps) {
return (
<div>
<h1>{title}</h1>
<p>Welcome, {user.name}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
export default withAuth(Dashboard);
// TypeScript knows Dashboard needs 'title' prop
// TypeScript knows 'user' and 'logout' are injected
<Dashboard title="My Dashboard" />
Advanced: Generic HOC
// withData.tsx
interface WithDataProps<T> {
data: T;
isLoading: boolean;
error: Error | null;
}
export function withData<T, P extends object>(
fetchData: () => Promise<T>
) {
return function (
Component: ComponentType<P & WithDataProps<T>>
): ComponentType<P> {
return function DataComponent(props: P) {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchData()
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <Component {...props} data={data} isLoading={false} error={null} />;
};
};
}
// Usage - full type inference!
interface User {
id: string;
name: string;
}
interface UserListProps {
filter: string;
}
function UserList({
filter,
data,
isLoading
}: UserListProps & WithDataProps<User[]>) {
const filtered = data.filter(user =>
user.name.includes(filter)
);
return (
<ul>
{filtered.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default withData<User[]>(fetchUsers)(UserList);
Composing Multiple HOCs
// The infamous wrapper hell
export default withAuth(
withTheme(
withRouter(
withData(fetchUsers)(
UserList
)
)
)
);
// Better: compose utility
function compose<P extends object>(
...hocs: Array<(component: ComponentType<any>) => ComponentType<any>>
) {
return (component: ComponentType<P>) =>
hocs.reduceRight(
(acc, hoc) => hoc(acc),
component
);
}
// Much cleaner
export default compose(
withAuth,
withTheme,
withRouter,
withData(fetchUsers)
)(UserList);
HOC Pros and Cons
Pros:
- ✅ Logic encapsulation
- ✅ Can modify props
- ✅ Works with class components
- ✅ Clear dependency chain
Cons with TypeScript:
- ❌ Type inference can break with multiple HOCs
- ❌ Wrapper hell in DevTools
- ❌ Prop naming collisions
- ❌ Difficult to trace where props come from
- ❌ Can't use inside component body
When to use HOCs:
- Legacy codebases with class components
- When you need to modify component props
- When composing third-party libraries
- When the pattern is already established
Pattern 2: Render Props
Basic Render Props with TypeScript
// Auth.tsx
interface User {
id: string;
name: string;
email: string;
}
interface AuthRenderProps {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
interface AuthProps {
children: (props: AuthRenderProps) => React.ReactNode;
}
export function Auth({ children }: AuthProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const login = async (email: string, password: string) => {
const user = await loginAPI(email, password);
setUser(user);
};
const logout = () => {
setUser(null);
};
useEffect(() => {
// Check for existing session
checkSession().then(setUser).finally(() => setIsLoading(false));
}, []);
return children({ user, login, logout, isLoading });
}
// Usage - full type safety!
function App() {
return (
<Auth>
{({ user, login, logout, isLoading }) => {
if (isLoading) return <div>Loading...</div>;
if (!user) {
return <LoginForm onLogin={login} />;
}
return (
<div>
<h1>Welcome, {user.name}</h1>
<button onClick={logout}>Logout</button>
</div>
);
}}
</Auth>
);
}
Generic Render Props
// DataFetcher.tsx
interface DataFetcherRenderProps<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
interface DataFetcherProps<T> {
fetchData: () => Promise<T>;
children: (props: DataFetcherRenderProps<T>) => React.ReactNode;
}
export function DataFetcher<T>({
fetchData,
children
}: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetch = useCallback(() => {
setIsLoading(true);
setError(null);
fetchData()
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false));
}, [fetchData]);
useEffect(() => {
fetch();
}, [fetch]);
return children({
data,
isLoading,
error,
refetch: fetch
});
}
// Usage - TypeScript infers T from fetchUsers
interface User {
id: string;
name: string;
}
function UserList() {
return (
<DataFetcher fetchData={fetchUsers}>
{({ data, isLoading, error, refetch }) => {
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
// TypeScript knows data is User[]
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}}
</DataFetcher>
);
}
Render Props with Default Rendering
// Toggle.tsx
interface ToggleRenderProps {
isOn: boolean;
toggle: () => void;
setOn: (value: boolean) => void;
}
interface ToggleProps {
defaultOn?: boolean;
children?: (props: ToggleRenderProps) => React.ReactNode;
// Optional: provide default UI
render?: (props: ToggleRenderProps) => React.ReactNode;
}
export function Toggle({
defaultOn = false,
children,
render
}: ToggleProps) {
const [isOn, setIsOn] = useState(defaultOn);
const toggle = () => setIsOn(prev => !prev);
const setOn = (value: boolean) => setIsOn(value);
const renderProps: ToggleRenderProps = { isOn, toggle, setOn };
// Use render prop if provided, otherwise children, otherwise default
if (render) return render(renderProps);
if (children) return children(renderProps);
// Default UI
return (
<button onClick={toggle}>
{isOn ? 'ON' : 'OFF'}
</button>
);
}
// Usage - multiple ways
// 1. Default UI
<Toggle defaultOn={true} />
// 2. Children render prop
<Toggle>
{({ isOn, toggle }) => (
<label>
<input type="checkbox" checked={isOn} onChange={toggle} />
{isOn ? 'Enabled' : 'Disabled'}
</label>
)}
</Toggle>
// 3. Render prop
<Toggle
render={({ isOn, toggle }) => (
<div className={isOn ? 'active' : 'inactive'} onClick={toggle}>
Click me
</div>
)}
/>
Render Props Composition (The Pain)
// ❌ Callback hell
function App() {
return (
<Auth>
{({ user }) => (
<Theme>
{({ theme }) => (
<DataFetcher fetchData={fetchPosts}>
{({ data: posts }) => (
<WindowSize>
{({ width }) => (
<Dashboard
user={user}
theme={theme}
posts={posts}
width={width}
/>
)}
</WindowSize>
)}
</DataFetcher>
)}
</Theme>
)}
</Auth>
);
}
Render Props Pros and Cons
Pros:
- ✅ Explicit data flow
- ✅ No prop naming collisions
- ✅ Can customize rendering completely
- ✅ Good TypeScript inference
Cons:
- ❌ Callback hell with multiple render props
- ❌ Harder to test
- ❌ Extra components in tree
- ❌ Can't use inside component body
- ❌ Performance overhead (function creation)
When to use Render Props:
- When you need complete rendering control
- When building headless UI components
- When the consumer needs to decide layout
- Legacy codebases using this pattern
Pattern 3: Hooks (The Winner)
Basic Hook
// useAuth.ts
interface User {
id: string;
name: string;
email: string;
}
interface UseAuthReturn {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
export function useAuth(): UseAuthReturn {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const login = useCallback(async (email: string, password: string) => {
const user = await loginAPI(email, password);
setUser(user);
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
useEffect(() => {
checkSession()
.then(setUser)
.finally(() => setIsLoading(false));
}, []);
return { user, login, logout, isLoading };
}
// Usage - clean and simple!
function App() {
const { user, login, logout, isLoading } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!user) {
return <LoginForm onLogin={login} />;
}
return (
<div>
<h1>Welcome, {user.name}</h1>
<button onClick={logout}>Logout</button>
</div>
);
}
Generic Hook
// useData.ts
interface UseDataOptions {
autoFetch?: boolean;
}
interface UseDataReturn<T> {
data: T | null;
isLoading: boolean;
error: Error | null;
refetch: () => void;
}
export function useData<T>(
fetchData: () => Promise<T>,
options: UseDataOptions = {}
): UseDataReturn<T> {
const { autoFetch = true } = options;
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(autoFetch);
const [error, setError] = useState<Error | null>(null);
const fetch = useCallback(() => {
setIsLoading(true);
setError(null);
fetchData()
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false));
}, [fetchData]);
useEffect(() => {
if (autoFetch) {
fetch();
}
}, [autoFetch, fetch]);
return { data, isLoading, error, refetch: fetch };
}
// Usage - beautiful type inference
interface User {
id: string;
name: string;
}
function UserList() {
const { data, isLoading, error, refetch } = useData(fetchUsers);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
// TypeScript knows data is User[]
return (
<div>
<button onClick={refetch}>Refresh</button>
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
Composing Hooks (The Dream)
// No nesting, no wrappers, just composition!
function Dashboard() {
const { user } = useAuth();
const { theme } = useTheme();
const { data: posts } = useData(fetchPosts);
const { width } = useWindowSize();
return (
<div className={theme}>
<h1>Welcome, {user.name}</h1>
<div>Window width: {width}px</div>
<PostList posts={posts} />
</div>
);
}
Hooks Pros and Cons
Pros:
- ✅ No wrapper components
- ✅ Perfect composition
- ✅ Excellent TypeScript inference
- ✅ Testable in isolation
- ✅ Can use conditionally (with rules)
- ✅ Smaller bundle size
- ✅ Better DevTools experience
Cons:
- ❌ Rules of Hooks (can't use in loops/conditions)
- ❌ Can't use in class components
- ❌ Learning curve for beginners
- ❌ Requires React 16.8+
When to use Hooks:
- Always, if you can
- New features and components
- Any modern React codebase
Side-by-Side Comparison
The Same Logic: Three Ways
Scenario: Fetch and display user data with loading and error states.
// 1. HOC Version
interface WithUserDataProps {
userData: User[];
isLoading: boolean;
error: Error | null;
}
function withUserData<P extends object>(
Component: ComponentType<P & WithUserDataProps>
): ComponentType<P> {
return function UserDataComponent(props: P) {
const [userData, setUserData] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUsers()
.then(setUserData)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
return (
<Component
{...props}
userData={userData}
isLoading={isLoading}
error={error}
/>
);
};
}
// Usage
function UserList({ userData, isLoading, error }: WithUserDataProps) {
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <ul>{userData.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
export default withUserData(UserList);
// 2. Render Props Version
interface UserDataRenderProps {
userData: User[];
isLoading: boolean;
error: Error | null;
}
function UserDataProvider({
children
}: {
children: (props: UserDataRenderProps) => React.ReactNode
}) {
const [userData, setUserData] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUsers()
.then(setUserData)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
return children({ userData, isLoading, error });
}
// Usage
function UserList() {
return (
<UserDataProvider>
{({ userData, isLoading, error }) => {
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <ul>{userData.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}}
</UserDataProvider>
);
}
// 3. Hook Version
interface UseUserDataReturn {
userData: User[];
isLoading: boolean;
error: Error | null;
}
function useUserData(): UseUserDataReturn {
const [userData, setUserData] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUsers()
.then(setUserData)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
return { userData, isLoading, error };
}
// Usage
function UserList() {
const { userData, isLoading, error } = useUserData();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <ul>{userData.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
TypeScript Comparison
| Feature | HOC | Render Props | Hooks |
|---|---|---|---|
| Type Inference | 🟡 Breaks with nesting | 🟢 Excellent | 🟢 Excellent |
| Prop Typing | 🔴 Complex generics | 🟢 Straightforward | 🟢 Straightforward |
| Return Type | 🟡 Needs explicit typing | 🟢 Inferred | 🟢 Inferred |
| Generic Support | 🟡 Complex | 🟢 Good | 🟢 Excellent |
| Ref Forwarding | 🔴 Difficult | N/A | 🟢 Easy |
Migration Strategies
HOC to Hook
// Before: HOC
interface WithAuthProps {
user: User;
logout: () => void;
}
function withAuth<P extends object>(
Component: ComponentType<P & WithAuthProps>
) {
return function AuthComponent(props: P) {
const user = useAuth();
const logout = useLogout();
return <Component {...props} user={user} logout={logout} />;
};
}
export default withAuth(Dashboard);
// After: Hook
function Dashboard() {
const user = useAuth();
const logout = useLogout();
// Component logic
}
Render Props to Hook
// Before: Render Props
<DataFetcher fetchData={fetchUsers}>
{({ data, isLoading, error }) => (
<UserList data={data} isLoading={isLoading} error={error} />
)}
</DataFetcher>
// After: Hook
function UserList() {
const { data, isLoading, error } = useData(fetchUsers);
// Component logic
}
Gradual Migration Strategy
// Step 1: Keep HOC/Render Prop, extract logic to hook
function useUserData() {
// Logic extracted here
}
function withUserData<P>(Component: ComponentType<P & WithUserDataProps>) {
return function(props: P) {
const userData = useUserData(); // Use the hook internally
return <Component {...props} {...userData} />;
};
}
// Step 2: Migrate components to use hook directly
// Step 3: Remove HOC once all components migrated
Hybrid Approaches
Hook + Render Prop (Headless UI Pattern)
// Provide both APIs!
interface SelectRenderProps<T> {
value: T | null;
isOpen: boolean;
toggle: () => void;
select: (value: T) => void;
}
interface SelectProps<T> {
value: T | null;
onChange: (value: T) => void;
children: (props: SelectRenderProps<T>) => React.ReactNode;
}
// Hook for internal logic
export function useSelect<T>(
value: T | null,
onChange: (value: T) => void
) {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(prev => !prev);
const select = (value: T) => {
onChange(value);
setIsOpen(false);
};
return { value, isOpen, toggle, select };
}
// Component using render prop
export function Select<T>({ value, onChange, children }: SelectProps<T>) {
const selectProps = useSelect(value, onChange);
return children(selectProps);
}
// Usage - consumers choose!
// 1. Use the component with render prop
<Select value={value} onChange={setValue}>
{({ isOpen, toggle, select }) => (
<div>
<button onClick={toggle}>Select</button>
{isOpen && <Options onSelect={select} />}
</div>
)}
</Select>
// 2. Or use the hook directly
function CustomSelect() {
const { isOpen, toggle, select } = useSelect(value, setValue);
return (
<div>
<button onClick={toggle}>Select</button>
{isOpen && <Options onSelect={select} />}
</div>
);
}
Hook + HOC for Legacy Support
// Modern hook
export function useFeatureFlags() {
const [flags, setFlags] = useState<Record<string, boolean>>({});
useEffect(() => {
fetchFeatureFlags().then(setFlags);
}, []);
return flags;
}
// Legacy HOC wrapper for class components
export function withFeatureFlags<P extends object>(
Component: ComponentType<P & { flags: Record<string, boolean> }>
): ComponentType<P> {
return function FeatureFlagsComponent(props: P) {
const flags = useFeatureFlags();
return <Component {...props} flags={flags} />;
};
}
// Modern components use hook
function ModernComponent() {
const flags = useFeatureFlags();
// ...
}
// Legacy class components use HOC
class LegacyComponent extends React.Component<{ flags: Record<string, boolean> }> {
render() {
const { flags } = this.props;
// ...
}
}
export default withFeatureFlags(LegacyComponent);
Real-World Decision Tree
Do you have class components?
├─ Yes → HOC or Render Props
└─ No → Hooks
Do you need to modify component props?
├─ Yes → HOC
└─ No → Hooks or Render Props
Do consumers need rendering control?
├─ Yes → Render Props or Hooks + Render Props
└─ No → Hooks
Is this a new feature?
├─ Yes → Hooks
└─ No → Match existing pattern
Do you need to compose multiple concerns?
├─ Yes → Hooks (much cleaner)
└─ No → Any pattern works
Are you building a library?
├─ Yes → Hooks + Render Props (both APIs)
└─ No → Hooks
Performance Comparison
Bundle Size Impact
// HOC - adds wrapper component
// Bundle: +500 bytes per HOC
// Render Props - adds component + render function
// Bundle: +600 bytes per render prop
// Hooks - just a function
// Bundle: +200 bytes per hook
Runtime Performance
// HOC
// - Creates wrapper components (extra tree depth)
// - Props spreading overhead
// - React.memo needed to prevent re-renders
// Render Props
// - Creates components (extra tree depth)
// - Function creation on every render
// - Callback overhead
// Hooks
// - No extra components
// - Optimized by React
// - useMemo/useCallback when needed
Best Practices by Pattern
HOC Best Practices
// ✅ Forward refs
export function withAuth<P extends object>(
Component: ComponentType<P>
) {
return forwardRef<any, P>((props, ref) => {
const auth = useAuth();
return <Component ref={ref} {...props} {...auth} />;
});
}
// ✅ Hoist non-react statics
import hoistNonReactStatics from 'hoist-non-react-statics';
export function withAuth<P>(Component: ComponentType<P>) {
const WithAuth = (props: P) => {
// ...
};
return hoistNonReactStatics(WithAuth, Component);
}
// ✅ Display names for DevTools
WithAuth.displayName = `WithAuth(${Component.displayName || Component.name})`;
Render Props Best Practices
// ✅ Memoize render functions
function App() {
const renderUsers = useCallback(({ users }: { users: User[] }) => (
<UserList users={users} />
), []);
return <UserDataProvider>{renderUsers}</UserDataProvider>;
}
// ✅ Provide default rendering
interface ToggleProps {
children?: (props: ToggleRenderProps) => ReactNode;
render?: (props: ToggleRenderProps) => ReactNode;
}
export function Toggle({ children, render }: ToggleProps) {
const props = useToggle();
if (render) return render(props);
if (children) return children(props);
return <DefaultToggleUI {...props} />;
}
Hook Best Practices
// ✅ Return objects, not arrays (usually)
// Better
function useAuth() {
return { user, login, logout };
}
// Unless mimicking useState
function useToggle(initialValue: boolean) {
return [value, toggle] as const;
}
// ✅ Prefix with 'use'
function useWindowSize() { /* ... */ }
// ✅ Extract to custom hooks early
function MyComponent() {
// ❌ Complex logic in component
const [users, setUsers] = useState([]);
useEffect(() => { /* ... */ }, []);
// ✅ Extract to hook
const users = useUsers();
}
Conclusion
The composition pattern landscape has evolved:
HOCs (2015-2018):
- Legacy pattern, still useful for class components
- TypeScript support is complex
- Use when you must, not by default
Render Props (2017-2019):
- Transitional pattern
- Good TypeScript support
- Use for headless UI libraries
- Avoid in new applications
Hooks (2019-Present):
- Modern standard
- Excellent TypeScript support
- Use for everything new
- Migrate to hooks when refactoring
The decision matrix:
- New code: Hooks
- Class components: HOC
- Headless UI: Hooks + Render Props
- Library API: Hooks + optional Render Props
- Legacy: Match existing pattern
TypeScript makes hooks even better than in JavaScript. Type inference works perfectly, composition is clean, and there's no wrapper hell.
If you're starting fresh: use hooks. If you're maintaining legacy code: migrate to hooks. The era of HOCs and Render Props is over.
Hooks won.
Which pattern do you use most? Share your migration stories in the comments!
Top comments (0)