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:

  1. Render Prop Pattern
  2. Function as a Child
  3. Higher Order Component
  4. Content Projection
  5. Component as a Property (you're here)
  6. Component as a Property with Generics
  7. Compound Components
  8. Mapped Components
  9. 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.