Higher-Order Component Typing Patterns
Higher-order components are functions that take a component and return an enhanced component. TypeScript makes HOCs challenging because the generic type parameters must be preserved through the composition layer. By combining generic constraints, conditional types, and utility types like React.ComponentType and React.PropsWithChildren, you can create HOCs that are fully type-safe and maintain excellent type inference, ensuring that wrapped components keep their original prop signatures while gaining new functionality.
Basic HOC Type Structure
The simplest type-safe HOC wraps a component and injects props while preserving the original prop interface:
import React from "react";
// Assume we have a component with specific props
interface UserCardProps {
userId: string;
userName: string;
onUpdate?: (userId: string) => void;
}
const UserCard: React.FC<UserCardProps> = ({
userId,
userName,
onUpdate,
}) => (
<div>
{userName} (ID: {userId})
{onUpdate && <button onClick={() => onUpdate(userId)}>Update</button>}
</div>
);
// Create a HOC that injects userId from a context
interface WithUserIdProps {
userId?: string; // Props injected by the HOC
}
const withUserId = <P extends { userId: string }>(
Component: React.ComponentType<P>
): React.FC<Omit<P, "userId">> => {
return (props) => {
const userId = "injected-user-123"; // From context or props
return <Component {...(props as P)} userId={userId} />;
};
};
// Wrap the component
const EnhancedUserCard = withUserId(UserCard);
// Now EnhancedUserCard doesn't require userId (it's injected)
<EnhancedUserCard userName="Alice" />;
// TypeScript error: userName is required
<EnhancedUserCard />;
The key pattern is:
- Define a generic constraint
P extends { property: Type }that requires the wrapped component to accept certain props. - Use
Omit<P, "property">to remove the injected props from the returned component's interface. - Inject the props inside the HOC function before calling the wrapped component.
HOC with Multiple Injected Props
For HOCs that inject multiple props, create an interface for injected props and use intersection types:
interface WithAuthProps {
isAuthenticated: boolean;
currentUserId: string;
}
const withAuth = <P extends WithAuthProps>(
Component: React.ComponentType<P>
): React.FC<Omit<P, keyof WithAuthProps>> => {
return (props) => {
const isAuthenticated = true; // From auth context
const currentUserId = "user-456";
return (
<Component
{...(props as P)}
isAuthenticated={isAuthenticated}
currentUserId={currentUserId}
/>
);
};
};
interface ProtectedPageProps extends WithAuthProps {
pageTitle: string;
children: React.ReactNode;
}
const ProtectedPage: React.FC<ProtectedPageProps> = ({
isAuthenticated,
currentUserId,
pageTitle,
children,
}) => {
if (!isAuthenticated) {
return <div>Not authenticated</div>;
}
return (
<div>
<h1>{pageTitle}</h1>
<p>User: {currentUserId}</p>
{children}
</div>
);
};
const EnhancedProtectedPage = withAuth(ProtectedPage);
// withAuth removes isAuthenticated and currentUserId from required props
<EnhancedProtectedPage pageTitle="Admin Panel">
Content
</EnhancedProtectedPage>;
Chaining HOCs with Type Preservation
When you compose multiple HOCs, types can become difficult to track. Use a helper function to maintain clarity:
// Helper to apply multiple HOCs
const compose = <P,>(
...hocs: Array<(Component: React.ComponentType<any>) => React.ComponentType<any>>
) => (Component: React.ComponentType<P>): React.ComponentType<P> => {
return hocs.reduce((acc, hoc) => hoc(acc), Component) as React.ComponentType<P>;
};
// Define individual HOCs
const withTheme = <P extends { theme: "light" | "dark" }>(
Component: React.ComponentType<P>
): React.FC<Omit<P, "theme">> => {
return (props) => (
<Component {...(props as P)} theme="light" />
);
};
const withTracking = <P extends { trackingId: string }>(
Component: React.ComponentType<P>
): React.FC<Omit<P, "trackingId">> => {
return (props) => {
React.useEffect(() => {
console.log("Tracked component mounted");
}, []);
return <Component {...(props as P)} trackingId="track-123" />;
};
};
interface PageProps {
theme: "light" | "dark";
trackingId: string;
title: string;
}
const Page: React.FC<PageProps> = ({ theme, trackingId, title }) => (
<div data-theme={theme}>
<h1>{title}</h1>
</div>
);
// Compose multiple HOCs
const EnhancedPage = compose(withTheme, withTracking)(Page);
// All injected props are removed from the required interface
<EnhancedPage title="Home" />;
Display Name and Forward Refs
For debugging and ref forwarding, enhance your HOC with proper display names and forwardRef:
const withForwardRef = <
P extends Record<string, any>,
R extends any = HTMLDivElement
>(
Component: React.ForwardRefExoticComponent<
P & React.RefAttributes<R>
>
): React.FC<P> & { displayName: string } => {
const Wrapped = React.forwardRef<R, P>((props, ref) => (
<Component {...props} ref={ref} />
));
const displayName = `Wrapped(${Component.displayName || Component.name || "Component"})`;
Wrapped.displayName = displayName;
return Wrapped as React.FC<P> & { displayName: string };
};
const MyDiv = React.forwardRef<HTMLDivElement, { title: string }>(
({ title }, ref) => <div ref={ref}>{title}</div>
);
MyDiv.displayName = "MyDiv";
const Enhanced = withForwardRef(MyDiv);
console.log(Enhanced.displayName); // "Wrapped(MyDiv)"
HOC Typing Patterns Table
| Pattern | Use Case | Code Pattern |
|---|---|---|
| Simple injection | One prop injected | Omit<P, "propName"> |
| Multiple injected | Many props from HOC | Omit<P, keyof InjectedProps> |
| Compose HOCs | Chain multiple HOCs | Use a compose helper |
| Forward ref | Pass ref through HOC | React.forwardRef<R, P> |
| Display name | Debugging in DevTools | Set Wrapped.displayName |
Key Takeaways
- HOC type safety relies on generic constraints:
P extends { injectedProp: Type }ensures the wrapped component accepts injected props. - Use
Omit<P, "injectedProp">to remove injected props from the wrapped component's returned type, so consumers don't pass them. - Compose HOCs with a helper function to maintain type clarity when chaining multiple enhancements.
- Forward refs and display names improve debugging and enable ref passing through HOC layers.
- HOCs are less common in modern React (hooks are preferred), but understanding their types deepens your TypeScript knowledge.
Frequently Asked Questions
Why do HOCs require generics instead of just extending props?
HOCs must preserve the original component's exact prop interface while removing injected props. Without generics, TypeScript would lose information about what props the wrapped component actually needs. Generics maintain that information through the transformation.
When should I use HOCs vs. hooks for adding functionality?
Modern React prefers custom hooks over HOCs for most use cases. Hooks are simpler to compose, don't create wrapper components, and are easier to type. Use HOCs when you need to transform components (e.g., for library distribution) or when you need to wrap render methods.
How do I type an HOC that accepts children?
Include React.ReactNode in your component's props: interface PageProps { children: React.ReactNode; theme: "light" | "dark"; }. The HOC automatically preserves children in the wrapped type.
Can I use conditional types in HOCs for more flexible typing?
Yes. You can write HOCs that use conditional types to adjust the returned component's interface based on the input component's props. This creates even more powerful abstractions but can become complex; use sparingly.