Skip to main content

Configuration vs Composition

Mar 06 2025 · 13 min

Listen to the article

In modern React development, developers often face a choice between two fundamental patterns: configuration and composition. This article explores how a seemingly simple implementation of the configuration pattern can evolve into increasing complexity, and how this complexity can be resolved through composition—examining the trade-offs and considerations along the way.

To illustrate these patterns and their implications, let’s examine a practical example that many developers encounter when building web applications.

Configuration

Let’s imagine a topbar component that stays mostly the same throughout the application, yet its call to action (CTA) may change in certain contexts. One way to approach this would be to configure the CTA via props associated with its functionality.

Let’s assume for now that the CTA will always be a button. The interface for the Topbar might look like this:

interface TopbarProps {
  ctaText: string;
  onCtaClick: VoidFunction;
  children?: ReactNode;
}

This approach works when the CTA is always a button with a consistent interface. But what happens when we need to render the CTA as a link in some cases?

One solution would be to extend the Topbar’s interface to handle the underlying polymorphism of the CTA, ensuring type safety for both variants.

A first iteration of this more complex Topbar could introduce a new href prop that’s mutually exclusive with the click handler. The component would render the CTA as an anchor tag when the href is present. However, we want to ensure that a click handler is always required when the href prop isn’t set. Since the CTA is a key UI element, we want TypeScript to warn us if we forget to associate an action with it.

The Topbar interface might now evolve into something like this:

interface BaseTopbarProps {
  ctaText: string;
  children?: ReactNode;
}

interface CtaLinkProps extends BaseTopbarProps {
  href: string;
  onCtaClick?: never;
}

interface CtaButtonProps extends BaseTopbarProps {
  href?: never;
  onCtaClick: VoidFunction;
}

type TopbarProps = CtaLinkProps | CtaButtonProps;

const Topbar: FC<TopbarProps> = ({
  ctaText,
  children,
  ctaHref,
  onCtaClick,
}) => (
  <div>
    {children}
    {ctaHref ? (
      <a href={ctaHref}>{ctaText}</a>
    ) : (
      <button onClick={onCtaClick}>{ctaText}</button>
    )}
  </div>
);

If we try to pass both a click handler and an href to the Topbar, TypeScript will helpfully warn us about our mistake:

/*
Type '{ ctaText: string; ctaHref: string; onCtaClick: () => string; }' is not assignable to type 'IntrinsicAttributes & TopbarProps'.
  Types of property 'onCtaClick' are incompatible.
    Type '() => void' is not assignable to type 'undefined'.
*/
<Topbar
  ctaText="Action!"
  ctaHref="/some/path"
  onCtaClick={() => {
    console.log('do something');
  }}
/>

This is, of course, a simple and naive case, where we assume developers would never need to use a click handler together with an href on a link. However, there are edge cases where this might be necessary—for instance, when global state needs to be updated during navigation.

The interface for the Topbar is already becoming complex, and imagine if we needed to pass even more props to the link or button. To make this slightly more manageable, we could introduce a ctaAs prop to the Topbar to better differentiate between button and link interfaces.

Let’s look at what that might look like:

interface BaseTopbarProps {
  ctaText: string;
  children?: ReactNode;
}

interface CtaLinkProps extends BaseTopbarProps {
  ctaAs: 'link';
  ctaHref: string;
  ctaTarget: HTMLAttributeAnchorTarget;
  ctaOnClick?: VoidFunction;
}

interface CtaButtonProps extends BaseTopbarProps {
  ctaAs: 'button';
  ctaHref?: never;
  ctaTarget?: never;
  ctaOnClick: VoidFunction;
}

type TopbarProps = CtaLinkProps | CtaButtonProps;

const Topbar: FC<TopbarProps> = ({
  ctaText,
  ctaAs,
  children,
  ctaHref,
  ctaTarget,
  ctaOnClick,
}) => (
  <div>
    {children}
    {ctaAs === 'link' ? (
      <a href={ctaHref} target={ctaTarget} onClick={ctaOnClick}>
        {ctaText}
      </a>
    ) : (
      <button onClick={ctaOnClick}>{ctaText}</button>
    )}
  </div>
);

When we pass an incorrect set of props to the Topbar, TypeScript warns us accordingly:

/*
Type '{ ctaText: string; ctaAs: "button"; ctaHref: string; }' is not assignable to type 'IntrinsicAttributes & TopbarProps'.
  Types of property 'ctaHref' are incompatible.
    Type 'string' is not assignable to type 'undefined'.ts(2322)
*/
<Topbar ctaText="Action!" ctaAs="button" ctaHref="/some/path" />

While this approach works, maintaining interfaces for the polymorphic CTA becomes tedious and bloated. Each prop for either the button or anchor must be included in the opposing interface and marked as optional and never.

As more props need to be passed to the CTA, the interface would become increasingly complex and unwieldy. The next logical step might be to simply expose the entire interface of both the button and anchor tag directly through the Topbar props.

Here’s how our resulting Topbar interface would look:

interface BaseTopbarProps {
  ctaText: string;
  children?: ReactNode;
}

interface CtaLinkProps extends BaseTopbarProps {
  ctaAs: 'link';
  ctaProps: ComponentProps<'a'>;
}

interface CtaButtonProps extends BaseTopbarProps {
  ctaAs: 'button';
  ctaProps: ComponentProps<'button'>;
}

type TopbarProps = CtaLinkProps | CtaButtonProps;

const Topbar: FC<TopbarProps> = ({ ctaText, ctaAs, children, ctaProps }) => (
  <div>
    {children}
    {ctaAs === 'link' ? (
      <a {...ctaProps}>{ctaText}</a>
    ) : (
      <button {...ctaProps}>{ctaText}</button>
    )}
  </div>
);

As you can see, this greatly simplifies the CTA-related props interface, making it more similar to our initial approach.

Let’s examine how we would configure a topbar with this new interface:

/*
Type '{ ctaText: string; ctaAs: "button"; ctaProps: { onClick: () => void; href: string; }; }' is not assignable to type 'IntrinsicAttributes & TopbarProps'.
  Types of property 'ctaProps' are incompatible.
    Type '{ onClick: () => void; href: string; }' is not assignable to type 'DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>'.
      Property 'href' does not exist on type 'DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>'. Did you mean 'ref'?ts(2322)
*/
<Topbar
  ctaText="Action!"
  ctaAs="button"
  ctaProps={{
    onClick: () => {
      console.log('do something');
    },
    href: '/some/path',
  }}
/>

At this point, I find using the Topbar component becomes rather awkward. We’ve created an entanglement of prefixed props that narrow and define the interface of other props, along with polymorphism in the component itself. From my experience, components like this tend to evolve into overengineered and unmaintainable messes. While bending TypeScript into creative contortions can be entertaining—and I’ll admit, I enjoy it—it often leads to more problems later. This becomes especially apparent when other developers need to use the component and require a mini-tutorial just to understand the magic hidden in this elaborate prop configuration.

Composition

The solution, however, is surprisingly simple. Instead of configuring the CTA through props that expose all options of a certain element, we can simply accept a ReactNode for the CTA. This approach embraces React’s composition pattern by exposing the UI through render slots, keeping the component’s internal structure clean and transparent.

As a result, the interface and code for the Topbar becomes elegant and straightforward:

interface TopbarProps {
  cta: ReactNode;
  children?: ReactNode;
}

const Topbar: FC<TopbarProps> = ({ children, cta }) => (
  <div>
    {children}
    {cta}
  </div>
);

When using this approach, we get a clear and direct representation of the CTA markup:

<Topbar cta={<a href="/some/path">link</a>} />;

<Topbar
  cta={
    <button
      onClick={() => {
        console.log('do something');
      }}
    >
      Click me
    </button>
  }
/>;

While this approach is elegant, it does come with a trade-off. Our configuration interface strictly limited which elements could be rendered in the topbar’s CTA—either a link or a button. This type safety isn’t possible with the compositional slot pattern, which leaves the topbar open to potential misuse if an unintended component is passed to the slot.

With only naming conventions providing a safety net, developers can pass any element to the CTA slot:

<Topbar cta={<div>something entirely different</div>} />

Compared to the configuration interface, losing the specificity might seem like a dealbreaker. However, as the component’s complexity increases, the flexibility and ease of use of the composition pattern can significantly outweigh the type safety of the configuration pattern.

For a more realistic use case of the compositional slot pattern, let’s examine a Modal component that offers slots for a header, body section, and footer.

With the composition pattern, the component becomes as simple as this:

interface ModalProps {
  header: ReactNode;
  body: ReactNode;
  footer: ReactNode;
}

const Modal: FC<ModalProps> = ({ header, body, footer }) => (
  <div>
    {header}
    {body}
    {footer}
  </div>
);

In contrast, passing configuration for each of these elements to the Modal component would result in a severely bloated and overly complex interface.

Conclusion

In essence, both patterns serve distinct purposes in React development. While composition offers elegance and flexibility through its slot-based approach, the choice between composition and configuration should be guided by your component’s specific requirements. Consider composition when flexibility in UI elements is paramount, and lean towards configuration when type safety and strict control over rendered elements are essential. The key is recognizing which trade-offs best serve your application’s needs.