React Children Map and cloneElement using TypeScript

React Children Map and cloneElement using TypeScript

Mapping through React Compound Components and Conditionally Enriching Children Props

This article shows how you can use the React Children map function together with React cloneElement function to add additional props to direct children of a component. Besides, the article demonstrates how you can check the type of a child component and modify it based on their type.

This approach is useful when building a compound component in React. For example, imagine you were building the following tabs.

TabsDemo.gif

This component would be implemented using compound components like this:

<Tabs>
    <Tabs.Title>Admin</Tabs.Title>
    <Tabs.Item id="overview">Overview</Tabs.Item>
    <Tabs.Item id="settings">Settings</Tabs.Item>
</Tabs>

If you are wondering how you can define and export Compound Components using TypeScript, check out my previous article React Compound Components Typings.

In this example, clicking on Tabs.Item makes the tab active and shows a corresponding active style. Here is how TabsItem was defined:

// TabsItem.tsx
import styles from "./Tabs.module.scss";
import { FunctionComponent } from "react";
import { combineClassNames } from "../../utils/combineClassNames";

export interface TabsItemProps {
    id: string;
    isActive?: boolean;
    onClick?: () => void;
}

export const TabsItem: FunctionComponent<TabsItemProps> = ({
    children,
    isActive = false,
    onClick,
}) => (
    <div
        className={combineClassNames(
            styles.item,
            isActive ? styles.active : null
        )}
        onClick={onClick}
    >
        {children}
    </div>
);

The Tabs component is responsible for storing the active tab and ensuring that when a tab is clicked, the active tab state gets updated.

Here is the complete implementation of Tabs:

// Tabs.tsx
import styles from "./Tabs.module.scss";
import {
    FunctionComponent,
    useState,
    Children,
    PropsWithChildren,
    ReactElement,
    cloneElement,
} from "react";
import { TabsTitle } from "./TabsTitle";
import { TabsItem, TabsItemProps } from "./TabsItem";

interface TabsComposition {
    Title: FunctionComponent;
    Item: FunctionComponent<TabsItemProps>;
}

const Tabs: FunctionComponent & TabsComposition = ({ children, ...props }) => {
    const [activeTab, setActiveTab] = useState<string>();
    return (
        <div className={styles.tabs}>
            {Children.map(children, (child) => {
                const item = child as ReactElement<PropsWithChildren<TabsItemProps>>;

                if (item.type === TabsItem) {
                    const isActive = item.props.id === activeTab;
                    const onClick = () => {
                        setActiveTab(item.props.id);
                        item.props.onClick?.();
                    };
                    return cloneElement(item, { isActive, onClick });
                } else {
                    return child;
                }
            })}
        </div>
    );
};

Tabs.Title = TabsTitle;
Tabs.Item = TabsItem;

export { Tabs };

Let's break it down.

In the Tabs component, we can store which tab is the active using useState hook.

const [activeTab, setActiveTab] = useState<string>();

Tabs component maps over the children and modifies them. The simplest way of using React's Children map API is equivalent to {children} and illustrated below:

{Children.map(children, (child) => {
    return child;
})}

This code is traversing each direct child of the Tabs component and returning it as is. However, simply returning each child is not very useful. Typically, we want to make some modifications to the child elements. For example, we might want to add an isActive prop in addition to the props which were already defined on the Tab.Item component.

To enrich child components with additional props we can use React cloneElement. This function accepts two arguments: the first is the element to be cloned, and the second argument is an object with additional props that will be added to the newly cloned element.

{Children.map(children, (child) => {
    // ...
    return cloneElement(child, { isActive, onClick });
})}

To correctly set the isActive prop and onClick event so that they are based on the activeTab state, we need to get the id props of each Tab.Item. Since we are using TypeScript we must explicitly set the typing ReactElement<PropsWithChildren<TabsItemProps>> which allows us to use the TabsItemProps interface when working with the child props. Thus, we get all the development benefits of type checking, IntelliSense, etc.

{Children.map(children, (child) => {
    const item = child as ReactElement<PropsWithChildren<TabsItemProps>>;
    const isActive = item.props.id === activeTab;
    const onClick = () => {
        setActiveTab(item.props.id);
        item.props.onClick?.();
    };
    return cloneElement(child, { isActive, onClick });
})}

While the code above works well when we only have Tab.Item children, it adds props to any component regardless of their type. For example, it would add the isActive prop to the Tab.Title child element, which is not intended behavior.

The desired behavior is adding props only on the Tab.Item child elements. Fortunately, we can check the type of the child element components. If this type matches TabsItem we are able to clone it and add props, otherwise we return the child as it is.

if (item.type === TabsItem) {
    //...
    return cloneElement(child, { isActive, onClick });
} else {
    return child;
}

To sum up, when building compound components such as the Tabs you can leverage React's Children.map function together with cloneElement to enrich component props. Finally, you can check the type of each child and conditionally modify child elements.