Zach Steiner
Dec 31, 2023

How I Write Components in 2023

  • JavaScript
  • React
  • Design Systems

tl;dr: Composition based components make writing library components more flexible and easier to use. Avoid the dreaded propspocolypse.

I've been writing components for at least a decade now. Maybe a bit longer if you include weird templating hacks in Microsoft FrontPage circa 1999. My technique and technologies have evolved over the years. The first pass at a component library was a global CSS (Sass powered) with HTML patterns. This eventually gave way to more complex Storybook-based component libraries. Most recently I've been working on ClearKit at Clearbit. My approach to component architecture and API has evolved considerably since I took over the reins. To start with, here's how I might have approached a component API when I was ramping up:

import { CKCollapsibleCard } from 'clearkit';
import { Info } from '@clearkit/icons';

return (<CKCollapsibleCard
  className="...classes"
  footer={<Footer />}
  header={<Header />}
>
  <p>Card body content</p>
</CKButton>);

This API is pretty standard for React components. The type would look something like this:

type CKCollapsibleCardProps = {
  children: React.ReactNode;
  className?: string;
  footer?: React.ReactNode;
  header?: React.ReactNode;
};

Then this API fell right on its face. When going through the variants we needed, sometimes the footer for the card needs to be always visible and sometimes it needs to collapse with the body, leading to the prop isBodyFooter. Then the cracks are began to show. Next I realized that sometimes the body and footer needed different styling (e.g., a background color or different padding). Next there were cases the card needed be open based on presence of data. Now a straight forward set of 4 props doubles to at least 8. The propspocolypse begins.

type CKCollapsibleCardProps = {
  children: React.ReactNode;
  className?: string;
  footer?: React.ReactNode;
  footerClassName?: string;
  header?: React.ReactNode;
  headerClassName?: string;
  isBodyFooter?: boolean;
  isDefaultOpen?: boolean;
};

How to streamline this? Composition! Let's work backwards from the end state.

const [isOpen, setIsOpen] = useState(false);

return (
<CKCollapsibleCard
  className="...classes"
  isOpen={isOpen}
  onToggle={setIsOpen}
>
  <CKCollapsibleCard.Header className="...classes">
    <CKCollapsibleCard.Title>
      Title
    </CKCollapsibleCard.Title>
    <h3>Subtitle</h3>
  </CKCollapsibleCard.Header>
  <CKCollapsibleCard.Body className="...classes">
    <p>Card body content</p>
  </CKCollapsibleCard.Body>
  <CKCollapsibleCard.Footer className="...classes">
    Footer content
  </CKCollapsibleCard.Footer>
</CKCardCollapsible>);

This looks a bit more verbose that a list of props, but it has two big advantages:

  1. It's much more flexible. Each sub-component has a full props API without the parent component needing more and more props. Sub-components mostly pass children.
  2. It's more readable. It looks like ... gasp ... HTML!

First let's talk about extensibility. Remember the case of the footer need to be sometimes always visible and sometimes in the body? Easy, just move the footer sub-component into the body sub-component.

const [isOpen, setIsOpen] = useState(false);

return (
<CKCollapsibleCard
  isOpen={isOpen}
  onToggle={setIsOpen}
>
   <CKCollapsibleCard.Header>
    <CKCollapsibleCard.Title>
      Title
    </CKCollapsibleCard.Title>
    <h3>Subtitle</h3>
   </CKCollapsibleCard.Header>
   <CKCollapsibleCard.Body>
   <p>Card body content</p>
   <CKCollapsibleCard.Footer>
    Footer content
   </CKCollapsibleCard.Footer>
  </CKCollapsibleCard.Body>
</CKCardCollapsible>);

Now the footer collapses with the body. The footer can have its background color via className. What about an aria-label on the header? Just add it to its props. Each of these sub-components can a consistent children plus whatever other props you need. In fact, we can extend attributes for footer or header or any other HTML element using TypeScript. This way our containers always have access to the 100 plus attributes without the component author playing whack-a-mole with adding props for specific needs (I've been there!).

For the children, you can pass JSX directly or create small components that encapsulate their content. Whatever we need in the consuming app, we can do it.

What about the default open behavior? Notice in the example, the component is now controlled (i.e., isOpen and onToggle) from the parent. This is adds further extensibility. By default this component handles its toggling internally, but when we need to start open or need the parent to be aware of the card's state, we can pass in the props to control it. The batteries included version of the component requires few props, but we can let the parent take over when needed. Why not just isDefaultOpen? This could be useful, but we found that every time we needed this, we also needed the parent to know about the toggling. For instance, the app needs execute other logic when the card toggles like validation, analytics, or closing other cards. Being able to switch to controlled makes this possible without sacrificing the ability to drop a component into a page and have it work.

Looking at the above code, one might wonder how the body and header know about the isOpen and onToggle props. This is where React Context comes in. The parent component provides the context and the children consume it. This is a great way to avoid prop drilling.

Let's dive into the full implementation to see how it all comes together:

import classnames from 'classnames';
import React, {
  createContext,
  FC,
  HTMLAttributes,
  ReactNode,
  useContext,
  useEffect,
  useState,
} from 'react';

import {
  excludeChildrenByDisplayName,
  includeChildrenByDisplayName,
} from '../../utils/mapChildren';

// Normally this is imported imported to show this type
type CKContainerProps<T = HTMLDivElement>
  = HTMLAttributes<T> & {
  children?: ReactNode;
}

export type CKCardCollapsibleProps = CKContainerProps & {
  /**
   * Set the max-height of card body content so that the body will scroll if content is long.
   * Accepts any valid CSS height unit.
   * @default max-content
   **/
  cardBodyMaxHeight?: string;
  isOpen?: boolean;
  onToggle?: () => void;
}

export interface CKCardCollapsibleComposition {
  Header: FC<CKContainerProps>;
  Trigger: FC<CKContainerProps>;
  Body: FC<CKContainerProps>;
  Footer: FC<CKContainerProps>;
}

const cardPadding = 'p-6';
const cardBorder = 'border-gray-100 border-t';

type CardContextValues = {
  handleCardToggle: () => void;
}

const CardContext = createContext<
  Omit<CKCardCollapsibleProps, 'children' | 'className' | 'onToggle'> &
    CardContextValues
>({
  cardBodyMaxHeight: 'max-content',
  handleCardToggle: () => {},
  isOpen: false,
});

export const CKCardCollapsible: FC<CKCardCollapsibleProps> &
  CKCardCollapsibleComposition = ({
  cardBodyMaxHeight = 'max-content',
  children,
  className,
  isOpen,
  onToggle,
  ...rest
}) => {
  const isControlled = isOpen != undefined && !!onToggle;
  const [isOpenInternal, setIsOpenInternal] = useState(isControlled && isOpen);

  useEffect(() => {
    if (isControlled) {
      setIsOpenInternal(!!isOpen);
    }
  }, [isOpen]);

  const handleCardToggle = () => {
    if (!isControlled) {
      setIsOpenInternal(!isOpenInternal);
    }

    onToggle?.();
  };

  return (
    <CardContext.Provider
      value={{
        cardBodyMaxHeight,
        isOpen: isOpenInternal,
        handleCardToggle,
      }}
    >
      <div
        {...rest}
        className={classnames(className, 'ck-box will-change-transform')}
        variant="card"
      >
        {children}
      </div>
    </CardContext.Provider>
  );
};

CKCardCollapsible.displayName = 'CKCardCollapsible';

CKCardCollapsible.Header = ({ children, className, ...rest }) => {
  const { isOpen, handleCardToggle } = useContext(CardContext);

  const headerClasses = classnames(
    cardPadding,
    'ck-card-header',
    'rounded-t-md transition-[border-radius] duration-300 ease-out',
    {
      'rounded-b-md': !isOpen,
    },
    className,
  );

  return (
    <header {...rest} className={headerClasses}>
      <button
        aria-label="toggle card"
        className="ck-card-header__toggle"
        onClick={handleCardToggle}
      />
      <div className="min-w-0">
        {excludeChildrenByDisplayName({
          children,
          componentDisplayName: 'CKCardCollapsible.Trigger',
        })}
      </div>
    </header>
  );
};
CKCardCollapsible.Header.displayName = 'CKCardCollapsible.Header';

CKCardCollapsible.Body = ({ children, className, ...rest }) => {
  const { cardBodyMaxHeight, isOpen } = useContext(CardContext);

  return isOpen ? (
    <div {...rest} className={className}>
      <div
        className={classnames(cardPadding, {
          'ck-scrollbar ck-scrollbar--vertical':
            cardBodyMaxHeight !== 'max-content',
        })}
        style={{ maxHeight: cardBodyMaxHeight }}
      >
        {excludeChildrenByDisplayName({
          children,
          componentDisplayName: 'CKCardCollapsible.Footer',
        })}
      </div>
      {includeChildrenByDisplayName({
        children,
        componentDisplayName: 'CKCardCollapsible.Footer',
      })}
    </div>
  ) : null;
};
CKCardCollapsible.Body.displayName = 'CKCardCollapsible.Body';

CKCardCollapsible.Footer = ({ children, className, ...rest }) => {
  const footerClasses = classnames(
    'rounded-b-md',
    className,
  );

  return <footer {...rest} className={footerClasses}>{children}</footer>;
};
CKCardCollapsible.Footer.displayName = 'CKCardCollapsible.Footer';

You can see how the component is wired up through context and how the sub-components are composed. The CKCardCollapsible component only has a handful of props that it passes down to context and mostly becomes a wrapper for its children. For instance, CKCardCollapsible.Body handles all the heavy lifting of computing heights for open and closed. The trigger component handles its toggling and whether to render a custom trigger or the default.

You will notice utilities excludeChildrenByDisplayName, and includeChildrenByDisplayName. These are custom utilities that make it easier to work with children. They filter children by their displayName. This creates an interface akin to slots in Vue or Svelte, but with a full React props API.

The internals of the component may seem a bit more verbose, but it's mostly boilerplate code. The pay off comes with consuming applications have an exceptionally flexible API that is easy to reason about and to implement. When design or product has subtle variations to the card, the library is ready. Need a "New" badge on the header? Just make a flex layout in the header sub-component. How about a icon in the header? Similar process. Buttons in the footer with complex data-driven visibility logic? Just pass a component as a child to the footer sub-component.

This was the component that really became the a-ha moment for me. I realized that components could be more flexible to allow for more use cases. I also realized that the API could be more readable and more like HTML. This is the approach I've taken with all new components in ClearKit and have refactored a lot of existing components to use this API. We've even extended this approach to consuming apps as well to cover page layout components.