Blog

Blog's main page
EN
Picture of the article: React compound components: Scalable and reusable

React compound components: Scalable and reusable

Building scalable and reusable components with React is fairly straightforward, but it’s just as easy to fall into the trap of adding too many props to cover an endless list of options.

Trying to build the perfect component that satisfies every possible end-user request is a doomed crusade. Instead, you should aim for simplicity—and the compound component pattern is a perfect fit for this goal.

Let’s build a highly flexible and versatile UserCard component to display user information (name, thumbnail, description, and skills).

The source code and a working example are available in this repository: GitHub – 31stfr/31st-ex.

For brevity, I'll use a simple reusable TypeScript type:

export type PropClassName = Pick<HTMLAttributes<HTMLElement>, 'className'>;

The Average Implementation

As a beginner, I often created components with too many props to cover every possible scenario. And yes, it worked: the component was functional and delivered what was expected.

But three weeks later, when the client changed their mind or asked for new features, the component became bloated and unmaintainable.

Here's a typical example of a monolithic component:

// ❌ Lack of flexibility
type BadUserCardProps = {
    className?: string;
    name: string;
    imageAlt: string;
    imageSrc: string;
    description: string;
    skills: string[];
};

const BadUserCard = ({
    name,
    imageAlt,
    imageSrc,
    description,
    skills,
    className,
}: BadUserCardProps) => {
    // ❌ No separation of responsibilities
    return (
        <div
            className={twMerge(
                'grid grid-cols-[auto_1fr] gap-4 p-4 bg-neutral-50 rounded-xl shadow-lg border border-neutral-200',
                className
            )}
        >
            <Image
                src={imageSrc}
                alt={imageAlt}
                width={150}
                height={150}
                className="rounded-lg max-w-[150px] max-h-[150px]"
            />
            <H5>{name}</H5>
            <div className="p-4 text-sm bg-white text-neutral-500 border rounded-lg">
                <ul>
                    {skills.map((skill) => (
                        <li key={skill}>{skill}</li>
                    ))}
                </ul>
            </div>
            <p className="p-4 text-sm bg-white text-neutral-500 border rounded-lg">{description}</p>
        </div>
    );
};

export default BadUserCard;

This component does the job—but it’s nearly impossible to replace or customize subcomponents. It lacks flexibility and doesn’t scale.

A Better Implementation

As an efficient developer, you should treat flexibility as a minimum requirement. While not always necessary, presentation components (like user cards or dashboard widgets) often need to adapt to multiple use cases within the same app.

That's where the compound component pattern shines: it allows you to build components designed to adapt.

This pattern requires only a React Context and one or more subcomponents.

Start with a Context

The context acts as a wrapper. Its provider is the root component you expose, and it brings several benefits:

  • Data sharing between subcomponents inside the main component

  • A guarantee that subcomponents are only used within the wrapper

  • A clear and convenient API for consumers

Here's a simple starting point:

type UserCardProps = PropsWithChildren & PropClassName;

interface UserCardContextData {
    checked: boolean;
}

// Context
const UserCardContext = createContext<UserCardContextData | undefined>(undefined);

const UserCard = ({ children, className }: UserCardProps) => {
    return (
        <UserCardContext value={{ checked: true }}>
            {children}
        </UserCardContext>
    );
};

export default UserCard;

This forms the base of our UserCard component. Notice that since React 19, you can render <UserCardContext> directly as a provider (without .Provider).

As a best practice, let's define a custom hook so consumer components can safely access the context:

// Hook
const useUserCardContext = () => {
    const context = use(UserCardContext);
    if (context === undefined) {
        throw new Error("UserCard components must be wrapped in a UserCardContextProvider");
    }

    return context;
};
Define the Root Styles

We can style the root UserCard component however we want, wrapping its children:

const UserCard = ({ children, className }: UserCardProps) => {
    return (
        <UserCardContext value={{ checked: true }}>
            <div
                className={twMerge(
                    'flex flex-wrap gap-4 p-4 bg-neutral-50 rounded-xl shadow-lg border border-neutral-200',
                    className
                )}
            >
                {children}
            </div>
        </UserCardContext>
    );
};
Adding Subcomponents

Now our component is ready to host as many subcomponents as we need.

In this example, we’ll add four:

  • Name

  • Image

  • Description

  • Skills

Each subcomponent is attached to the root component via static property assignment.

UserCard.Name = UserCardName;
UserCard.Image = UserCardImage;
UserCard.Description = UserCardDescription;
UserCard.Skills = UserCardSkills;

This allows consumers to write a clean API like:

<UserCard>
  <UserCard.Image src="/me.png" alt="Profile picture" />
  <UserCard.Name>Gregory</UserCard.Name>
  <UserCard.Description>Front-end developer specialized in React</UserCard.Description>
  <UserCard.Skills skills={['React', 'TypeScript', 'TailwindCSS']} />
</UserCard>

Complete code of our component

Note that it is recommended to place hooks in their own file (starting with use).

UserCard.tsx
type UserCardProps = PropsWithChildren & PropClassName;

type UserCardNameProps = PropsWithChildren;

type NextImageProps = ComponentProps<typeof Image>;
// Liskov Substitution principle ( Subtype objects should be substitutable for supertype objects )
type UserCardImageProps = NextImageProps;

type UserCardDescriptionProps = PropsWithChildren & PropClassName;

type UserCardSkillsProps = {
    skills: string[];
} & PropClassName;

interface UserCardContextData {
    checked: boolean;
}

// Context
const UserCardContext = createContext<UserCardContextData | undefined>(undefined);

// Hook
const useUserCardContext = () => {
    const context = use(UserCardContext);
    if (context === undefined) {
        throw new Error("UserCard component's must be wrapped in a UserCardContextProvider");
    }

    return context;
};

const UserCard = ({ children, className = undefined }: UserCardProps) => {
    return (
        <UserCardContext value={{ checked: true }}>
            <div
                className={twMerge(
                    'flex flex-wrap gap-4 p-4 bg-neutral-50 rounded-xl shadow-lg border border-neutral-200',
                    className
                )}
            >
                {children}
            </div>
        </UserCardContext>
    );
};

const UserCardName = ({ children }: UserCardNameProps) => {
    useUserCardContext();

    return (
        <div className="flex flex-col gap-1">
            <H6 className="text-xs uppercase">Name</H6>
            <H5>{children}</H5>
        </div>
    );
};

const UserCardImage = (props: UserCardImageProps) => {
    useUserCardContext();

    const { src, className, ...rest } = props;

    return (
        <Image
            className={twMerge('rounded-lg aspect-square max-w-[150px] max-h-[150px]', className)}
            src={src}
            width={150}
            height={150}
            {...rest}
        />
    );
};

const UserCardDescription = ({ children, className = undefined }: UserCardDescriptionProps) => {
    useUserCardContext();

    return (
        <div className="flex flex-col gap-1">
            <H6 className="text-xs uppercase">Description</H6>
            <div
                className={twMerge(
                    'p-4 text-sm bg-white text-neutral-500 border rounded-lg',
                    className
                )}
            >
                {children}
            </div>
        </div>
    );
};

const UserCardSkills = ({ skills, className = undefined }: UserCardSkillsProps) => {
    useUserCardContext();

    return (
        <div className="flex flex-col gap-1">
            <H6 className="text-xs uppercase">Skills</H6>
            <div
                className={twMerge(
                    'p-4 text-sm bg-white text-neutral-500 border rounded-lg',
                    className
                )}
            >
                <ul className="list-disc list-inside text-sm text-neutral-500">
                    {skills.map((skill) => (
                        <li key={skill}>{skill}</li>
                    ))}
                </ul>
            </div>
        </div>
    );
};

UserCard.Name = UserCardName;
UserCard.Image = UserCardImage;
UserCard.Description = UserCardDescription;
UserCard.Skills = UserCardSkills;

export default UserCard;

Key Takeaways

  • Each subcomponent is attached to the main component using static property assignment.

  • Each subcomponent calls useUserCardContext to ensure it’s wrapped in the provider.

  • Each subcomponent supports className for maximum customization.

  • Each subcomponent has its own props for clarity and flexibility.

  • The Image component uses Next.js’ Image, following the Liskov Substitution Principle (subtypes should be substitutable for their supertypes).

  • tailwind-merge is used extensively to make overriding styles easy.

Conclusion

The compound component pattern is a clean way to create scalable, reusable, and flexible UI components in React.

It prevents components from becoming rigid or bloated, while offering consumers a clear and intuitive API. Next time you catch yourself overloading a component with props, consider splitting responsibilities with compound components instead—you’ll thank yourself later.

About the author

Front-end developer focused on React, Next.js, and clean, scalable CSS. Once building in PHP, Flash and others, now crafting layouts that (mostly) behave as expected. Greg's background in web design (Photoshop, Illustrator) shaped his love for clean layouts and CSS details. This blog is his way of giving back-sharing what he has learned from the same community that keeps inspiring him.