Higher-Order Components (HOCs) used to be React’s go-to pattern for sharing logic between components. But with TypeScript, HOCs can become typing nightmares, especially when you need to thread props through multiple layers. Enter render props—a pattern that gives you the same power with far better type safety and IntelliSense support. We’ll explore why render props often win in the modern React + TypeScript world and show you practical alternatives that keep your code both flexible and type-safe.
The HOC Problem
Before we dive into solutions, let’s understand what we’re solving. HOCs wrap your components to inject props or behavior, but they come with some TypeScript pain points:
// ❌ Classic HOC - typing gets messy fast
function withAuth<P extends object>(Component: React.ComponentType<P & { user: User }>) {
return function AuthenticatedComponent(props: Omit<P, 'user'>) {
const user = useAuth();
if (!user) return <LoginForm />;
return <Component {...props} user={user} />;
};
}
// Using it requires type gymnastics
const ProfilePage = withAuth<{ title: string }>(({ user, title }) => {
// IntelliSense struggles here - it doesn't know about 'user'
return (
<h1>
{title}: {user.name}
</h1>
);
});The issues become more apparent when you start composing HOCs or when TypeScript can’t properly infer the injected props. Your editor’s autocomplete gets confused, error messages become cryptic, and refactoring becomes risky business.
Render Props: The TypeScript-Friendly Alternative
Render props flip the script. Instead of wrapping your component, you pass a function that receives the data and returns JSX. TypeScript loves this pattern because the data flow is explicit and type inference works beautifully.
// ✅ Render prop component with excellent TypeScript support
interface AuthRenderProps {
user: User | null;
isLoading: boolean;
error?: string;
}
interface AuthProviderProps {
children: (props: AuthRenderProps) => React.ReactNode;
fallback?: React.ReactNode;
}
function AuthProvider({ children, fallback }: AuthProviderProps) {
const { user, isLoading, error } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user && fallback) return <>{fallback}</>;
return <>{children({ user, isLoading, error })}</>;
}
// Usage is clean and type-safe
function ProfilePage() {
return (
<AuthProvider fallback={<LoginForm />}>
{({ user }) => (
<div>
<h1>Welcome, {user?.name}!</h1>
{/* Full IntelliSense support - TypeScript knows user exists here */}
</div>
)}
</AuthProvider>
);
}The magic happens in that children prop type: (props: AuthRenderProps) => React.ReactNode. TypeScript knows exactly what data is available inside the render function, giving you perfect autocomplete and error checking.
Generic Render Props for Maximum Flexibility
When you need to handle different data types, generics make render props even more powerful:
interface DataFetcher<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
interface FetchDataProps<T> {
url: string;
children: (state: DataFetcher<T>) => React.ReactNode;
}
function FetchData<T>({ url, children }: FetchDataProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return <>{children({ data, loading, error, refetch: fetchData })}</>;
}
// Usage with different data types - TypeScript infers everything
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
return (
<FetchData<User> url={`/api/users/${userId}`}>
{({ data: user, loading, error, refetch }) => {
if (loading) return <div>Loading user...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2> {/* TypeScript knows this is a User! */}
<p>{user.email}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
}}
</FetchData>
);
}Notice how TypeScript automatically infers that user is of type User inside the render function. No manual type assertions needed.
Custom Hooks: The Modern Alternative
Sometimes you don’t need the component wrapper at all. Custom hooks can provide the same logic sharing with even cleaner syntax:
function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const checkAuth = async () => {
try {
const response = await fetch('/api/me');
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Auth check failed');
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const logout = useCallback(async () => {
await fetch('/api/logout', { method: 'POST' });
setUser(null);
}, []);
return { user, isLoading, error, logout };
}
// Usage is incredibly clean
function ProfilePage() {
const { user, isLoading, error, logout } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return <LoginForm />;
return (
<div>
<h1>Welcome, {user.name}!</h1>
<button onClick={logout}>Sign Out</button>
</div>
);
}Custom hooks give you the best of both worlds: shared logic without component wrapping, and TypeScript support that’s as good as it gets.
Compound Components with Render Props
For more complex UI patterns, you can combine render props with compound components to create flexible, reusable interfaces:
interface DropdownContextValue {
isOpen: boolean;
toggle: () => void;
close: () => void;
selectedValue: string | null;
select: (value: string) => void;
}
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
interface DropdownProps {
children: React.ReactNode;
onSelect?: (value: string) => void;
}
function Dropdown({ children, onSelect }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
const close = useCallback(() => setIsOpen(false), []);
const select = useCallback(
(value: string) => {
setSelectedValue(value);
onSelect?.(value);
close();
},
[onSelect, close],
);
const contextValue: DropdownContextValue = {
isOpen,
toggle,
close,
selectedValue,
select,
};
return (
<DropdownContext.Provider value={contextValue}>
<div className="relative">{children}</div>
</DropdownContext.Provider>
);
}
// Render prop child component
interface DropdownListProps {
children: (context: DropdownContextValue) => React.ReactNode;
}
function DropdownList({ children }: DropdownListProps) {
const context = useContext(DropdownContext);
if (!context) {
throw new Error('DropdownList must be used within Dropdown');
}
if (!context.isOpen) return null;
return (
<div className="absolute top-full left-0 rounded border bg-white shadow-lg">
{children(context)}
</div>
);
}
// Usage combines the best of both patterns
function UserSelector() {
return (
<Dropdown onSelect={(userId) => console.log('Selected:', userId)}>
<button>Select User</button>
<DropdownList>
{({ select, selectedValue }) => (
<FetchData<User[]> url="/api/users">
{({ data: users, loading }) => {
if (loading) return <div className="p-2">Loading...</div>;
return (
<>
{users?.map((user) => (
<button
key={user.id}
onClick={() => select(user.id.toString())}
className={`block w-full p-2 text-left hover:bg-gray-100 ${
selectedValue === user.id.toString() ? 'bg-blue-50' : ''
}`}
>
{user.name}
</button>
))}
</>
);
}}
</FetchData>
)}
</DropdownList>
</Dropdown>
);
}This pattern gives you maximum flexibility while maintaining excellent type safety. The render prop receives the full context with proper typing, and you can compose different data sources easily.
Performance Considerations
One common concern with render props is performance. Since you’re creating a new function on each render, React might unnecessarily re-render child components. Here are some strategies to optimize:
// ✅ Memoize expensive render functions
function ExpensiveComponent() {
const renderUsers = useCallback(({ data: users, loading }: DataFetcher<User[]>) => {
if (loading) return <div>Loading...</div>;
return (
<div>
{users?.map((user) => (
<ExpensiveUserCard key={user.id} user={user} />
))}
</div>
);
}, []);
return <FetchData<User[]> url="/api/users">{renderUsers}</FetchData>;
}
// ✅ Or use React.memo for the render prop component
const MemoizedFetchData = React.memo(FetchData) as typeof FetchData;When to Use Each Pattern
Here’s a practical guide for choosing the right pattern:
Use Custom Hooks when:
- You’re sharing stateful logic between components
- The logic doesn’t need to control rendering
- You want the cleanest possible syntax
- The shared data is relatively simple
Use Render Props when:
- You need to control when/how child components render
- You’re building reusable UI components
- The component needs to handle loading states, errors, etc.
- You want to compose multiple data sources
Use Compound Components when:
- You’re building complex, multi-part UI components
- You need to share state between distant child components
- You want API flexibility (users can arrange parts differently)
- The component has multiple distinct pieces that work together
Avoid HOCs when:
- You’re using TypeScript (unless you really know what you’re doing)
- You need to compose multiple behaviors
- The wrapped component’s props need to be transparent to consumers
Real World Use Cases™
Here are some practical scenarios where these patterns shine:
Form Field Management
interface FieldState<T> {
value: T;
error: string | null;
touched: boolean;
setValue: (value: T) => void;
setTouched: () => void;
validate: () => boolean;
}
interface FormFieldProps<T> {
initialValue: T;
validator?: (value: T) => string | null;
children: (field: FieldState<T>) => React.ReactNode;
}
function FormField<T>({ initialValue, validator, children }: FormFieldProps<T>) {
const [value, setValue] = useState<T>(initialValue);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const validate = useCallback(() => {
if (validator) {
const validationError = validator(value);
setError(validationError);
return !validationError;
}
return true;
}, [value, validator]);
const handleSetValue = useCallback(
(newValue: T) => {
setValue(newValue);
if (touched && validator) {
setError(validator(newValue));
}
},
[touched, validator],
);
const handleSetTouched = useCallback(() => {
setTouched(true);
validate();
}, [validate]);
return (
<>
{children({
value,
error,
touched,
setValue: handleSetValue,
setTouched: handleSetTouched,
validate,
})}
</>
);
}
// Usage with perfect type inference
function SignupForm() {
return (
<form>
<FormField<string>
initialValue=""
validator={(email) => (email.includes('@') ? null : 'Please enter a valid email')}
>
{({ value, error, setValue, setTouched }) => (
<div>
<input
type="email"
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={setTouched}
className={error ? 'border-red-500' : ''}
/>
{error && <span className="text-red-500">{error}</span>}
</div>
)}
</FormField>
</form>
);
}API State Management
interface ApiState<T, E = unknown> {
data: T | null;
loading: boolean;
error: E | null;
}
function useApiState<T, E = string>(
apiCall: () => Promise<T>,
): ApiState<T, E> & { execute: () => Promise<void> } {
const [state, setState] = useState<ApiState<T, E>>({
data: null,
loading: false,
error: null,
});
const execute = useCallback(async () => {
setState((prev) => ({ ...prev, loading: true, error: null }));
try {
const data = await apiCall();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error as E,
});
}
}, [apiCall]);
return { ...state, execute };
}
// Clean usage with excellent error handling
function UserDashboard({ userId }: { userId: number }) {
const userApi = useApiState(() =>
fetch(`/api/users/${userId}`).then((r) => r.json() as Promise<User>),
);
useEffect(() => {
userApi.execute();
}, [userId, userApi.execute]);
if (userApi.loading) return <div>Loading user...</div>;
if (userApi.error) return <div>Error loading user</div>;
if (!userApi.data) return <div>User not found</div>;
return <UserProfile user={userApi.data} />;
}Migrating from HOCs
If you’re stuck with existing HOCs, here’s a gradual migration strategy:
// Step 1: Create a render prop version alongside your HOC
function withAuthRenderProp<T extends object>(Component: React.ComponentType<T & { user: User }>) {
return function AuthWrapper(props: Omit<T, 'user'>) {
return (
<AuthProvider>
{({ user }) => {
if (!user) return <LoginForm />;
return <Component {...(props as T)} user={user} />;
}}
</AuthProvider>
);
};
}
// Step 2: Gradually replace HOC usage with direct render props
// Old: const ProtectedPage = withAuth(MyPage);
// New: Direct usage of AuthProvider with render props
// Step 3: Eventually remove the HOC wrapper entirelyThe transition can be gradual, allowing you to improve type safety incrementally without breaking existing code.
Next Steps
Render props and custom hooks represent the modern way to share logic in React applications with TypeScript. They provide better type safety, clearer data flow, and superior developer experience compared to HOCs. Start by identifying shared logic in your codebase that could benefit from these patterns:
- Audit your HOCs: Look for components that are hard to type or compose
- Extract custom hooks: Convert simple stateful logic to hooks first
- Build render prop components: For more complex UI logic that needs to control rendering
- Combine patterns: Use compound components + render props for maximum flexibility
The goal isn’t to eliminate every HOC overnight, but to reach for these patterns when building new features. Your future self (and your teammates) will thank you for the improved type safety and cleaner code.