How to Pass Component as Prop in React and TypeScript
This article is part of a series about React techniques and patterns. Stay tuned for more. Soon, other articles will be linked here.
At times, coding necessitates a more versatile approach. Consider a scenario where you need to dynamically designate a component for rendering. In the realm of React, several techniques are available for such scenarios:
- Render Prop Pattern
- Function as a Child
- Higher Order Component
- Content Projection
- Component as a Property (you're here)
- Component as a Property with Generics
- Compound Components
- Mapped Components
- Context API - Yes, it may be used for this purpose too!
In this exploration, we'll focus on the Component as Property. We'll delve into implementing this approach using TypeScript and establish precise type definitions.
The Problem
Imagine rendering a dynamic tree widget for interconnected nodes. You pass a data
property and can opt to provide a custom NodeComponent. If none is specified, TreeDisplay
defaults to a predefined one.
Customization becomes crucial, especially when crafting reusable components that are library and application context-agnostic.
In typical applications already utilizing UI libraries, such advanced patterns may not be necessary. However, it depends on your context and use case.
API Design
The API of TreeDisplay
will be used in the following way:
import { TreeDisplay } from "@components";
const MyCustomNode = ({ node }) => {
return (
<div>
{node.label} + {node.id}
</div>
);
};
const App = () => {
const data = {
id: "1",
label: "Root",
children: [{ id: "2", label: "Nested", children: [] }]
};
return <TreeDisplay data={data} node={MyCustomNode} />;
};
So, we can assume that the interface
contract for TreeDisplay
will have the following shape:
// TreeDisplay.tsx file
import { ComponentType } from "react";
// Data object shape
interface TreeDisplayDataNode {
id: string | number;
label: string;
children: TreeDisplayDataNode[];
}
// Properties that will be passed to the "node" component
type TreeDisplayNodeProps = Pick<TreeDisplayDataNode, "id" | "label">;
// Properties for the "TreeDisplay" component
interface TreeDisplayProps {
data: TreeDisplayDataNode;
node: ComponentType<TreeDisplayNodeProps>;
}
Implementation
We won't spend time implementing real logic inside TreeDisplay
. Instead, we'll focus on maintaining type safety when writing code inside and when passing properties through.
// TreeDisplay.tsx file
const TreeDisplay = (props: TreeDisplayProps) => {
// Accessing these properties is safe here!
{ props.data.children, props.data.id, props.data.label }
const Node = props.node;
// Mapping to "Node" from the "data" property will occur here
return <Node id="1" label="Something" />;
};
// App.tsx file
import {
TreeDisplayNodeProps,
TreeDisplayDataNode,
TreeDisplay,
} from "@components/TreeDisplay";
const MyCustomNode = ({ id, label }: TreeDisplayNodeProps) => {
// Accessing these properties is safe here!
{ label, id }
return null;
};
const App = () => {
const data: TreeDisplayDataNode = {
id: "1",
label: "First level",
children: [{ id: "2", label: "Second level", children: [] }],
};
return <TreeDisplay data={data} node={MyCustomNode} />;
};
Limitations
This pattern works well when you're certain your components will consistently have data shapes defined in the interface
contract. However, a problem may arise when you want to pass additional data to your nodes. The current implementation
will block that option.
To accommodate this feature, you need to utilize generics
in the context of components or consider other patterns, such as Function as a Child or Render Prop. The choice should be based on your context and use case.
The concept of generics is too extensive in the context of components to cover in this article, so we'll skip it and handle it in another article.
Use Cases and Benefits
Limits Data Model Options
Useful when rendering business-related components, particularly concrete ones like UserGraph
.
Improved Re-rendering Performance
Achieved by passing a once-created function (which is a component) rather than passing an anonymous render prop multiple times.
Easier Maintenance
No need to deal with useCallback
or useMemo
when passing a custom rendering mechanism - this might be a consideration if you opt for a different pattern.
Summary
This pattern has rare use cases, but when employed in the right place, it can significantly enhance your developer experience and reusability. While it doesn't allow complete freedom with the data
model or component
shape, it does provide a balance of flexibility and limitations - sometimes exactly what's needed.
It's entirely up to you to decide when to apply a dedicated pattern or technique, so it's valuable to know and learn as much as possible to enrich your developer toolkit.