Skip to main content

Consistency over customisability

Strictness to prevent chaos


I work daily with component APIs. The challenge I face the most is not technical but philosophical: How should a component be consumed? In other words, how the API should be structured. The two paths I have experience with are the strict and the loose way.

Link to this headingThe loose way

Let's start with a flexible Button component that takes in children and className props:

import clsx from "clsx";
import { ReactNode } from "react";

type ButtonType = React.ComponentPropsWithoutRef<"button"> & {
  children: ReactNode;
  onClick?: () => void;
  className?: string;
};

const buttonStyles = "...";

export const Button = ({
  children,
  className,
  onClick,
  ...rest
}: ButtonType) => {
  return (
    <button
      className={clsx(buttonStyles, className)}
      onClick={onClick}
      {...rest}
    >
      {children}
    </button>
  );
};

Not bad! We have a working button component. As long as all of the developers in the team agree on how to use the component, this approach can work. I've seen this approach in use, especially in smaller projects. It's flexible, and the component can easily be customised to your needs.

Two months later, a new developer enters the team. She's tasked with building a view that has a button. This time, however, the button should show a loading state until some data is loaded. The developer checks the Button component and sees it accepts a children prop, so she goes and uses the component like this:

import { Button } from "@/components/Button";

export const SomeView = () => {
  // Other code here...
  return (
    <div className="flex">
      <Button
        className={`p-12 w-full ${isLoading ? "bg-gray-600" : "bg-blue-500"}`}
        disabled={isLoading}
        onClick={doSomething}
      >
        {isLoading ? "Loading..." : "Click here!"}
      </Button>
    </div>
  );
};

The loose API allowed the developer to build an ad hoc loading indicator for the button. That might be precisely what you want, but I see three challenges with this approach:

  1. The loading button style is detached from the component. If someone else wants to use the loading style, they should copy-paste the code or move the loading state to the Button component.
  2. Someone else could come up with a similar yet different solution somewhere else. This way, we'd end up with an inconsistent system.
  3. The consumer can come up with any content and functionality for the component.

To tackle these challenges, I propose another approach: a more strict API.

Link to this headingThe strict way

Let's take the same button component but make it more strict:

import clsx from "clsx";

type ButtonType = React.ComponentPropsWithoutRef<"button"> & {
  text: string;
  variant?: "primary" | "secondary";
  size?: "md" | "lg";
  onClick?: () => void;
  isLoading?: boolean;
  disabled?: boolean;
  fullWidth?: boolean;
};

const baseStyles = "...";
const sizeStyles = {
  md: "...",
  lg: "...",
};
const variantStyles = {
  primary: "...",
  secondary: "...",
};

const fullWidthStyles = "...";
const loadingStyles = "...";

export const Button = ({
  text,
  variant = "primary",
  size = "md",
  onClick,
  isLoading = false,
  disabled = false,
  fullWidth = false,
}: ButtonType) => {
  return (
    <button
      className={clsx(
        baseStyles,
        sizeStyles[size],
        variantStyles[variant],
        fullWidth && fullWidthStyles,
        isLoading && loadingStyles, // These could be part of the base styles too
      )}
      disabled={disabled}
      onClick={onClick}
    >
      {isLoading ? "Loading..." : text}
    </button>
  );
};

Now, when anyone consumes the button, the options are more limited:

<Button
  text="Click me!"
  size="lg"
  isLoading={isLoading}
  onClick={doSomething}
  fullWidth
/>

Let's review the features of this approach:

  1. The button doesn't accept the almighty children prop, which results in fewer options for the consumer. Depending on your preference, this is either good or bad. The button will always have the same content, either text or a loading state.
  2. The necessary styles are built beforehand. Note that sometimes it's OK to take the className prop, as you might need that for some contextual layout styling, although most of the time, it can and should be done on the parent level.
  3. It's immediately visible how this component can be used. Want to make it full width? Use the fullWidth prop. Want to use a different variant? Use the variant prop with predefined styles with type safety.

This approach removes guessing about how the component should be used. What we lose in flexibility, we gain in consistency.

In the future, if we need more features, it's easier to loosen up the API whenever new requirements surface. Moving from loose to strict is tougher, even outside programming, as parents know.

There is no right or wrong approach to component API design. In my experience, the more strict approach has been more consistent, easier to test and safer to use – especially when the team or project grows.

Get in touch

I'm not currently looking for freelancer work, but if you want to have a chat, feel free to contact me.

Contact