Skip to content

Instantly share code, notes, and snippets.

@RobinMalfait
Last active May 28, 2024 13:06
Show Gist options
  • Save RobinMalfait/490a0560a7cfde985d435ad93f8094c5 to your computer and use it in GitHub Desktop.
Save RobinMalfait/490a0560a7cfde985d435ad93f8094c5 to your computer and use it in GitHub Desktop.
import React, { ReactNode } from "react";
import { classNames } from "../utils/class-names";
enum Variant {
GRAY,
RED,
ORANGE,
YELLOW,
GREEN,
TEAL,
BLUE,
INDIGO,
PURPLE,
PINK
}
type Props = {
variant: Variant;
children?: ReactNode;
};
const VARIANT_MAPS: Record<Variant, string> = {
[Variant.GRAY]: "bg-gray-100 text-gray-800",
[Variant.RED]: "bg-red-100 text-red-800",
[Variant.ORANGE]: "bg-orange-100 text-orange-800",
[Variant.YELLOW]: "bg-yellow-100 text-yellow-800",
[Variant.GREEN]: "bg-green-100 text-green-800",
[Variant.TEAL]: "bg-teal-100 text-teal-800",
[Variant.BLUE]: "bg-blue-100 text-blue-800",
[Variant.INDIGO]: "bg-indigo-100 text-indigo-800",
[Variant.PURPLE]: "bg-purple-100 text-purple-800",
[Variant.PINK]: "bg-pink-100 text-pink-800"
};
export function Badge(props: Props) {
const { children, variant } = props;
return (
<span
className={classNames(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium leading-4 whitespace-no-wrap",
VARIANT_MAPS[variant]
)}
>
{children}
</span>
);
}
Badge.defaultProps = {
variant: Variant.GRAY
};
Badge.variant = Variant;
<Badge />
<Badge variant={Badge.variant.GRAY} />

Why this is bad (you might think)

  1. The file is big
  2. The file might look unmaintainable:
  • You have to update the class map & the enum (spoiler: TypeScript will ensure this is in sync)
  • There is a lot of duplicate code (the classNames per variant are almost the same except for the color)

Why this is good

  1. You know the exact states your component can be in
  2. There is no cleverness / dynamic classes going on -> More maintable
  3. There is no className prop, this means that we know what states / variants our component can be in, we don't introduce new implicit states (a large badge if we add className="text-xl")
  4. If one of the values need an extra class, you can add it, no need for clever tricks. (E.g.: Let's say a yellow color needs a darker text color)
  5. PurgeCSS can easily remove non-used css classes

Here is a bad example: https://gist.github.com/RobinMalfait/7cf7a0498039027a1d591f5f2b908a95

@crimelabs786
Copy link

crimelabs786 commented Aug 30, 2021

Hey, thanks for the gist. Landed here from Tailwind GitHub.

Since we're using TypeScript, is it safe to assume that consumer of this components would also likely be using TypeScript?

If so, we can ditch the enum and define Variant as union type (it's usually recommended to use union types over enum in TypeScript).

Even better (single source of truth) if you just go for as const arrays.

Here's how I'd define the same:

import React from "react";
import { classNames } from "../utils/class-names";

const variants = [
  "gray",
  "red",
  "orange",
  "yellow",
  "green",
  "teal",
  "blue",
  "indigo",
  "purple",
  "pink",
] as const;

type Variant = typeof variants[number]; // union type of those string literals defined in the array above

/**
 * Would be really cool to have a type like TailWindCSSClass
 * Instead of a string
 **/
const variantMaps: Record<Variant, string> = {
  // other ones ...
  red: "bg-red-100 text-red-800",
};

interface BadgeProps {
  variant: Variant;
}

const Badge: React.FC<BadgeProps> = (props) => {
  const { children, variant } = props;

  return (
    <span
      className={classNames(
        "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium leading-4 whitespace-no-wrap",
        classNames(variantMaps)
      )}
    >
      {children}
    </span>
  );
};

Benefit is that at the call-site when a consumer is consuming the component, they get autocomplete on the string values "red", "green" etc., and they don't have to know about Badge.variant object. It reduces surface area of the API.

If a new variant is to be added or an existing one needs to be removed / updated; only the array variants need to be modified, and TypeScript would do the rest.

There's one loose end with this: the variantMap might not be exhaustive. As in, it might not create record values for all variants, only a subset of it.

I don't know of a way to enforce this in TypeScript as of version 4.3.5. If this were a more type-safe language, like Elm or ReasonML, we could've used a function that takes variant as a parameter and returns a set of TailWindCSSClassnames, with a switch-case implementation. We could probably do a hack like this.

Interested in hearing your thoughts.

@neldeles
Copy link

Thanks for sharing your method @crimelabs786 . I'm fairly new to TS so this part was unclear to me:

only the array variants need to be modified, and TypeScript would do the rest.

I still had to manually update 2 places with the method you shared (i.e. variants and variantMaps). Just recently though I came across this awesome article by Kent Dodds on constrained identity functions. Have started using the CIF pattern instead.

// the constrained identity function
const createMaps = <ObjectMapType extends Record<string, string>>(
  obj: ObjectMapType
) => obj;

const variantMaps = createMaps({
    red: "bg-red-100 text-red-800",
});

export type ButtonProps = {
  variant: keyof typeof variantMaps;
};

With the CIF pattern, I now only need to update one place instead.

@dpschen
Copy link

dpschen commented Jan 24, 2024

In case someone stumbles over this again:

Is there anything wrong with using doing it like this, now that we have satisfies?

const VARIANT_MAPS = {
  gray: "bg-gray-100 text-gray-800",
  red: "bg-red-100 text-red-800",
  orange: "bg-orange-100 text-orange-800",
  // [...]
} satisfies Record<string, string>

export type ButtonProps = {
  variant: keyof typeof VARIANT_MAPS;
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment