Skip to main content

Typing Higher-Order Components and Render Props

Higher-order components (HOCs) and render props are advanced composition patterns that let you extract stateful logic and reuse it across components. Typing them correctly in TypeScript is subtle—you must preserve prop types from the wrapped component, manage generic constraints, and ensure the returned component is correctly inferred. Mastering these patterns is essential for building reusable logic without code duplication.

Understanding Higher-Order Components

A higher-order component is a function that takes a component and returns a new component with added functionality. For example, an HOC might add authentication logic or theme switching:

// withAuth.tsx
interface WithAuthProps {
isAuthenticated: boolean;
user?: { id: string; name: string };
}

const withAuth = <P extends WithAuthProps>(
Component: React.ComponentType<P>
) => {
return (props: Omit<P, keyof WithAuthProps>) => {
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
const [user, setUser] = React.useState<{ id: string; name: string } | undefined>(undefined);

React.useEffect(() => {
// Fetch auth state
setIsAuthenticated(true);
setUser({ id: '1', name: 'Alice' });
}, []);

return (
<Component
{...(props as P)}
isAuthenticated={isAuthenticated}
user={user}
/>
);
};
};

export default withAuth;

The HOC extracts the WithAuthProps from the original props and provides them automatically, letting callers omit isAuthenticated and user.

Typing an HOC that Wraps a Component

Properly type an HOC by parameterizing on the wrapped component's props and preserving all original props:

// withTheme.tsx
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}

interface WithThemeProps {
theme: ThemeContextType;
}

const withTheme = <P extends WithThemeProps>(
Component: React.ComponentType<P>
): React.FC<Omit<P, keyof WithThemeProps>> => {
const WrappedComponent: React.FC<Omit<P, keyof WithThemeProps>> = (props) => {
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');

const toggleTheme = () => {
setTheme((t) => (t === 'light' ? 'dark' : 'light'));
};

const themeContext: ThemeContextType = { theme, toggleTheme };

return <Component {...(props as P)} theme={themeContext} />;
};

WrappedComponent.displayName = `withTheme(${Component.displayName || Component.name})`;

return WrappedComponent;
};

export default withTheme;

Using an HOC with Type Safety

When you wrap a component with an HOC, TypeScript infers that the returned component no longer requires the injected props:

// ThemeButton.tsx
interface ThemeButtonProps {
label: string;
theme: ThemeContextType;
}

const ThemeButton: React.FC<ThemeButtonProps> = ({ label, theme }) => (
<button style={{ background: theme.theme === 'dark' ? '#333' : '#fff' }}>
{label} ({theme.theme})
</button>
);

// Wrap with withTheme
const ThemedButton = withTheme(ThemeButton);

// Usage—note that theme is NOT required
<ThemedButton label="Click me" />

// Error: ThemedButton doesn't accept theme prop (it's provided by HOC)
<ThemedButton label="Click me" theme={{theme: 'light', toggleTheme: () => {}}} />

The returned component's props exclude theme because the HOC provides it.

Render Props Pattern

Render props let a component accept a function that renders content. The function receives state from the parent component:

// DataFetcher.tsx
interface DataFetcherProps<T> {
url: string;
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}

const DataFetcher = <T,>({ url, children }: DataFetcherProps<T>) => {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);

React.useEffect(() => {
setLoading(true);
fetch(url)
.then((res) => res.json())
.then((data) => {
setData(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, [url]);

return children(data, loading, error);
};

export default DataFetcher;

Callers provide a function that receives the fetched data:

interface Post {
id: number;
title: string;
body: string;
}

// Usage
<DataFetcher<Post> url="/api/posts/1">
{(post, loading, error) => {
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div><h2>{post?.title}</h2><p>{post?.body}</p></div>;
}}
</DataFetcher>

The render function is fully typed—TypeScript knows post is Post | null, loading is boolean, and error is Error | null.

Combining HOC and Render Props

Use both patterns together for powerful composition:

// withData.tsx
interface WithDataProps<T> {
data: T | null;
loading: boolean;
error: Error | null;
}

const withData = <P extends WithDataProps<T>, T>(
Component: React.ComponentType<P>,
url: string
) => {
return (() => {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);

React.useEffect(() => {
fetch(url)
.then((res) => res.json())
.then((d) => {
setData(d);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);

return (
<Component
{...({} as P)}
data={data}
loading={loading}
error={error}
/>
);
}) as React.ComponentType<Omit<P, keyof WithDataProps<T>>>;
};

export default withData;

Typing Forward Refs with HOC

When wrapping a forwardRef component with an HOC, preserve the ref type:

// Input.tsx
interface InputProps {
placeholder: string;
value: string;
onChange: (value: string) => void;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ placeholder, value, onChange }, ref) => (
<input
ref={ref}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
)
);

Input.displayName = 'Input';

// withValidation.tsx
interface WithValidationProps {
error?: string;
onValidate?: (isValid: boolean) => void;
}

const withValidation = <P extends WithValidationProps>(
Component: React.ComponentType<P>
) => {
return React.forwardRef<HTMLInputElement, Omit<P, keyof WithValidationProps>>(
(props, ref) => {
const [error, setError] = React.useState('');

const validate = (value: string) => {
const isValid = value.length > 0;
setError(isValid ? '' : 'Required');
return isValid;
};

return (
<div>
<Component
{...(props as P)}
ref={ref}
error={error}
onValidate={validate}
/>
{error && <span className="error">{error}</span>}
</div>
);
}
);
};

// ValidatedInput.tsx
const ValidatedInput = withValidation(Input);

// Usage
const inputRef = React.useRef<HTMLInputElement>(null);

<ValidatedInput ref={inputRef} placeholder="Name" value="" onChange={() => {}} />

The ref is correctly forwarded through the HOC, and TypeScript knows it's an HTMLInputElement.

Custom Hook Pattern (Modern Alternative)

Modern React often prefers custom hooks over HOCs for logic reuse:

// useAuth.ts
interface User {
id: string;
name: string;
}

interface UseAuthReturn {
isAuthenticated: boolean;
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}

const useAuth = (): UseAuthReturn => {
const [isAuthenticated, setIsAuthenticated] = React.useState(false);
const [user, setUser] = React.useState<User | null>(null);

const login = async (email: string, password: string) => {
// Auth logic
setIsAuthenticated(true);
setUser({ id: '1', name: 'Alice' });
};

const logout = () => {
setIsAuthenticated(false);
setUser(null);
};

return { isAuthenticated, user, login, logout };
};

// Usage in component
const LoginForm: React.FC = () => {
const auth = useAuth();

const handleSubmit = async (email: string, password: string) => {
await auth.login(email, password);
};

return (
<div>
{auth.isAuthenticated ? (
<p>Welcome, {auth.user?.name}</p>
) : (
<form onSubmit={() => handleSubmit('[email protected]', 'pass')}>
{/* form fields */}
</form>
)}
</div>
);
};

Custom hooks are often simpler and easier to type than HOCs.

Key Takeaways

  • HOCs are functions that take a component and return a new component with added logic, but they require careful typing to preserve props.
  • Use Omit<P, keyof InjectedProps> to type the returned component's props, excluding the props injected by the HOC.
  • Render props let you pass a function to a component that receives state, enabling flexible composition without wrapper components.
  • Combine generics with render props to ensure the function's parameters are correctly typed based on the data being passed.
  • Forward refs through HOCs with React.forwardRef to preserve ref access to underlying DOM elements.
  • Modern React often prefers custom hooks over HOCs for simpler, more readable logic reuse.

Frequently Asked Questions

Why is HOC typing so complex compared to custom hooks?

HOCs modify component signatures and must preserve all original props while injecting new ones. Custom hooks are simpler because they're just functions that return values—no component wrapping. For new code, hooks are preferred.

Can I use TypeScript generics with HOC parameters?

Yes—<T, P extends WithAuthProps & T> allows you to combine multiple generic constraints. This is advanced but necessary for complex HOCs.

How do I type an HOC that wraps a class component?

HOCs work with class components too, but the syntax is slightly different. Constrain the generic to React.ComponentType (which includes both function and class components) and use React.PropsWithRef instead of Omit when dealing with refs.

What's the difference between render props and function children?

Function children is a pattern where children is a function. Render props is when you have an explicit render or renderItem prop. Both use the same typing pattern—a function type parameter with a return type of React.ReactNode.

Can I have multiple render props in a single component?

Yes—{ renderHeader, renderContent, renderFooter } are all render props. Type each as a function that returns React.ReactNode, with parameters specific to what data they need.

Further Reading