Cheatsheets for experienced React developers getting started with TypeScript
Basic | Advanced | Migrating | HOC | 中文翻译 | Contribute! | Ask!
This HOC Cheatsheet compiles all available knowledge for writing Higher Order Components with React and TypeScript.
- We will map closely to the official docs on HOCs initially
- While hooks exist, many libraries and codebases still have a need to type HOCs.
- Render props may be considered in future
- The goal is to write HOCs that offer type safety while not getting in the way.
Expand Table of Contents
In this first section we refer closely to the React docs on HOCs and offer direct TypeScript parallels.
Docs Example: Use HOCs For Cross-Cutting Concerns
Misc variables referenced in the example below
/** dummy child components that take anything */
const Comment = (_: any) => null;
const TextBlock = Comment;
/** dummy Data */
type CommentType = { text: string; id: number };
const comments: CommentType[] = [
{
text: "comment1",
id: 1
},
{
text: "comment2",
id: 2
}
];
const blog = "blogpost";
/** mock data source */
const DataSource = {
addChangeListener(e: Function) {
// do something
},
removeChangeListener(e: Function) {
// do something
},
getComments() {
return comments;
},
getBlogPost(id: number) {
return blog;
}
};
/** type aliases just to deduplicate */
type DataType = typeof DataSource;
// type TODO_ANY = any;
/** utility types we use */
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// type Optionalize<T extends K, K> = Omit<T, keyof K>;
/** Rewritten Components from the React docs that just uses injected data prop */
function CommentList({ data }: WithDataProps<typeof comments>) {
return (
<div>
{data.map((comment: CommentType) => (
<Comment comment={comment} key={comment.id} />
))}
</div>
);
}
interface BlogPostProps extends WithDataProps<string> {
id: number;
// children: ReactNode;
}
function BlogPost({ data, id }: BlogPostProps) {
return (
<div key={id}>
<TextBlock text={data} />;
</div>
);
}
Example HOC from React Docs translated to TypeScript
// these are the props to be injected by the HOC
interface WithDataProps<T> {
data: T; // data is generic
}
// T is the type of data
// P is the props of the wrapped component that is inferred
// C is the actual interface of the wrapped component (used to grab defaultProps from it)
export function withSubscription<T, P extends WithDataProps<T>, C>(
// this type allows us to infer P, but grab the type of WrappedComponent separately without it interfering with the inference of P
WrappedComponent: React.JSXElementConstructor<P> & C,
// selectData is a functor for T
// props is Readonly because it's readonly inside of the class
selectData: (
dataSource: typeof DataSource,
props: Readonly<JSX.LibraryManagedAttributes<C, Omit<P, "data">>>
) => T
) {
// the magic is here: JSX.LibraryManagedAttributes will take the type of WrapedComponent and resolve its default props
// against the props of WithData, which is just the original P type with 'data' removed from its requirements
type Props = JSX.LibraryManagedAttributes<C, Omit<P, "data">>;
type State = {
data: T;
};
return class WithData extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {
data: selectData(DataSource, props)
};
}
componentDidMount = () => DataSource.addChangeListener(this.handleChange);
componentWillUnmount = () =>
DataSource.removeChangeListener(this.handleChange);
handleChange = () =>
this.setState({
data: selectData(DataSource, this.props)
});
render() {
// the typing for spreading this.props is... very complex. best way right now is to just type it as any
// data will still be typechecked
return <WrappedComponent data={this.state.data} {...this.props as any} />;
}
};
// return WithData;
}
/** HOC usage with Components */
export const CommentListWithSubscription = withSubscription(
CommentList,
(DataSource: DataType) => DataSource.getComments()
);
export const BlogPostWithSubscription = withSubscription(
BlogPost,
(DataSource: DataType, props: Omit<BlogPostProps, "data">) =>
DataSource.getBlogPost(props.id)
);
Docs Example: Don’t Mutate the Original Component. Use Composition.
This is pretty straightforward - make sure to assert the passed props as T
due to the TS 3.2 bug.
function logProps<T>(WrappedComponent: React.ComponentType<T>) {
return class extends React.Component {
componentWillReceiveProps(
nextProps: React.ComponentProps<typeof WrappedComponent>
) {
console.log("Current props: ", this.props);
console.log("Next props: ", nextProps);
}
render() {
// Wraps the input component in a container, without mutating it. Good!
return <WrappedComponent {...this.props as T} />;
}
};
}
Docs Example: Pass Unrelated Props Through to the Wrapped Component
No TypeScript specific advice needed here.
Docs Example: Maximizing Composability
HOCs can take the form of Functions that return Higher Order Components that return Components.
connect
from react-redux
has a number of overloads you can take inspiration from in the source.
Here we build our own mini connect
to understand HOCs:
Misc variables referenced in the example below
/** utility types we use */
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
/** dummy Data */
type CommentType = { text: string; id: number };
const comments: CommentType[] = [
{
text: "comment1",
id: 1
},
{
text: "comment2",
id: 2
}
];
/** dummy child components that take anything */
const Comment = (_: any) => null;
/** Rewritten Components from the React docs that just uses injected data prop */
function CommentList({ data }: WithSubscriptionProps<typeof comments>) {
return (
<div>
{data.map((comment: CommentType) => (
<Comment comment={comment} key={comment.id} />
))}
</div>
);
}
const commentSelector = (_: any, ownProps: any) => ({
id: ownProps.id
});
const commentActions = () => ({
addComment: (str: string) => comments.push({ text: str, id: comments.length })
});
const ConnectedComment = connect(
commentSelector,
commentActions
)(CommentList);
// these are the props to be injected by the HOC
interface WithSubscriptionProps<T> {
data: T;
}
function connect(mapStateToProps: Function, mapDispatchToProps: Function) {
return function<T, P extends WithSubscriptionProps<T>, C>(
WrappedComponent: React.ComponentType<T>
) {
type Props = JSX.LibraryManagedAttributes<C, Omit<P, "data">>;
// Creating the inner component. The calculated Props type here is the where the magic happens.
return class ComponentWithTheme extends React.Component<Props> {
public render() {
// Fetch the props you want inject. This could be done with context instead.
const mappedStateProps = mapStateToProps(this.state, this.props);
const mappedDispatchProps = mapDispatchToProps(this.state, this.props);
// this.props comes afterwards so the can override the default ones.
return (
<WrappedComponent
{...this.props}
{...mappedStateProps}
{...mappedDispatchProps}
/>
);
}
};
};
}
Docs Example: Wrap the Display Name for Easy Debugging
This is pretty straightforward as well.
interface WithSubscriptionProps {
data: any;
}
function withSubscription<
T extends WithSubscriptionProps = WithSubscriptionProps
>(WrappedComponent: React.ComponentType<T>) {
class WithSubscription extends React.Component {
/* ... */
public static displayName = `WithSubscription(${getDisplayName(
WrappedComponent
)})`;
}
return WithSubscription;
}
function getDisplayName<T>(WrappedComponent: React.ComponentType<T>) {
return WrappedComponent.displayName || WrappedComponent.name || "Component";
}
Unwritten: Caveats section
- Don’t Use HOCs Inside the render Method
- Static Methods Must Be Copied Over
- Refs Aren’t Passed Through
This is covered in passing in Section 1 but we focus on it here as it is such a common issue. HOCs often inject props to premade components. The problem we want to solve is having the HOC-wrapped-component exposing a type that reflects the reduced surface area of props - without manually retyping the HOC every time. This involves some generics, fortunately with some helper utilities.
Say we have a component:
type DogProps {
name: string
owner: string
}
function Dog({name, owner}: DogProps) {
return <div> Woof: {name}, Owner: {owner}</div>
}
And we have a withOwner
HOC that injects the owner
:
const OwnedDog = withOwner("swyx")(Dog);
We want to type withOwner
such that it will pass through the types of any component like Dog
, into the type of OwnedDog
, minus the owner
property it injects:
typeof OwnedDog; // we want this to be equal to { name: string }
<Dog name="fido" owner="swyx" />; // this should be fine
<OwnedDog name="fido" owner="swyx" />; // this should have a typeError
<OwnedDog name="fido" />; // this should be fine
// and the HOC should be reusable for completely different prop types!
type CatProps = {
lives: number;
owner: string;
};
function Cat({ lives, owner }: CatProps) {
return (
<div>
{" "}
Meow: {lives}, Owner: {owner}
</div>
);
}
const OwnedCat = withOwner("swyx")(Cat);
<Cat lives={9} owner="swyx" />; // this should be fine
<OwnedCat lives={9} owner="swyx" />; // this should have a typeError
<OwnedCat lives={9} />; // this should be fine
So how do we type withOwner
?
- We get the types of the component:
keyof T
- We
Exclude
the property we want to mask:Exclude<keyof T, 'owner'>
, this leaves you with a list of names of properties you want on the wrapped component e.g.name
- (optional) Use intersection types if you have more to exclude:
Exclude<keyof T, 'owner' | 'otherprop' | 'moreprop'>
- Names of properties aren't quite the same as properties themselves, which also have an associated type. So we use this generated list of names to
Pick
from the original props:Pick<keyof T, Exclude<keyof T, 'owner'>>
, this leaves you with the new, filtered props, e.g.{ name: string }
- (optional) Instead of writing this manually each time, we could use this utility:
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
- Now we write the HOC as a generic function:
function withOwner(owner: string) {
return function<T extends { owner: string }>(
Component: React.ComponentType<T>
) {
return function(props: Omit<T, "owner">): React.ReactNode {
return <Component owner={owner} {...props} />;
};
};
}
Note: above is an incomplete, nonworking example. PR a fix!
We will need to extract lessons from here in future but here they are: