🌊

TailwindCSSでコンポーネントを作成するときに意識していること

2023/02/10に公開
4

概要

個人的に TailwindCSS を使ったコンポーネント作成をするときに意識していることをまとめます

1. ComponentPropsを使う

多くの人が述べていますが、拡張性を高めるためにReact.ComponentPropsを使います。

Button.tsx
import { ComponentProps, FC } from 'react'

type ButtonProps = {
  loading: boolean
} & ComponentProps<'button'>

export const Button: FC<ButtonProps> = ({ loading, children, ...props }) => {
  return <button {...props}>{loading ? children : 'Loading...'}</button>
}

このようにすると、ページで用いるときにデフォルトのボタンと同じような使用感で用いることができます。

2. tailwind-mergeを使う

tailwind-mergeclassNameの結合をいい感じにやってくれるライブラリです。
https://github.com/dcastil/tailwind-merge
以下は公式より引用します

What is it for
If you use Tailwind with a component-based UI renderer like React or Vue, you're probably familiar with the situation that you want to change some styles of a component, but only in one place.
// React components with JSX syntax used in this example

function MyGenericInput(props) {
  const className = `border rounded px-2 py-1 ${props.className || ''}`;
  return <input {...props} className={className} />;
}

function MySlightlyModifiedInput(props) {
  return (
    <MyGenericInput
      {...props}
      className='p-3' // ← Only want to change some padding
    />
  );
}

When the MySlightlyModifiedInput is rendered, an input with the className border rounded px-2 py-1 p-3 gets created. But because of the way the CSS cascade works, the styles of the p-3 class are ignored. The order of the classes in the className string doesn't matter at all and the only way to apply the p-3 styles is to remove both px-2 and py-1.
This is where tailwind-merge comes in.

function MyGenericInput(props) {
  // ↓ Now `props.className` can override conflicting classes
  const className = twMerge('border rounded px-2 py-1', props.className);
  return <input {...props} className={className} />;
}

tailwind-merge overrides conflicting classes and keeps everything else untouched. In the case of the MySlightlyModifiedInput, the input now only renders the classes border rounded p-3.

簡単にまとめると、
px-2 py-1ではなく、p-3にするときはpx-2 py-1を消さないとうまく繁栄されないよ!tw-mergeはそこらへんの処理をうまくやってくれるよ!」
ってことです。
このように元のclassNameをコンフリクトすることなくオーバーライドすることができます。
これを使ってベーススタイルを持ちつつ、拡張性の高いコンポーネントを作成できます。

Button.tsx
import { ComponentProps, FC } from 'react'
+import { twMerge } from 'tailwind-merge'

type ButtonProps = {
  loading: boolean
} & ComponentProps<'button'>

export const Button: FC<ButtonProps> = ({
  loading,
  children,
+  className,
  ...props
}) => {
+  const baseClass =
+    'inline-block px-4 py-2 text-xs font-bold text-white bg-blue rounded-full'
+  const mergedClass = twMerge(baseClass, className)
  return (
+    <button className={mergedClass} {...props}>
      {loading ? children : 'Loading...'}
    </button>
  )
}

3. refを使うときはforwardRefcomponentPropsWithRef

react-hook-foom で以下のような書き方を見ると思います。

<input {...register('name')} />

これはそのまま扱うことはできません。registerの戻り値にrefが含まれているからです。
その場合はforwardRefcomponentPropsWithRefを使ってコンポーネントを作成します。

Input.tsx
import { ComponentPropsWithRef, forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

type InputProps = {
  label: string
  // labelとinputのclassNameを分ける
  labelClassName?: string
  inputClassName?: string
} & Omit<ComponentPropsWithRef<'input'>, 'className'>

+export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, labelClassName, inputClassName, ...props }, ref): JSX.Element => {
    const baseLabelClass = 'text-xs font-bold text-gray-600'
    const baseInputClass =
      'w-full px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-md focus:border-blue focus:outline-none'
    const mergedLabelClass = twMerge(baseLabelClass, labelClassName)
    const mergedInputClass = twMerge(baseInputClass, inputClassName)
    return (
      <div className="flex flex-col">
        <label className={mergedLabelClass}>{label}</label>
+        <input ref={ref} className={mergedInputClass} {...props} />
      </div>
    )
  },
)

+Input.displayName = 'Input'

forwardRef<{HTMLの要素}, {Props}>という感じでジェネリクスに Type を入れます。
また、forwardRef を使用した場合、eslint などの設定によっては「コンポーネントの名前がないよ!」と怒られるので、明示的にInput.displayName = 'Input'と指定しています。

終わりに

このような使い回しの多いコンポーネントは型なども含め、きちんと定義した方が後々負債になりにくいです。
より良い開発体験を目指していきましょう!

GitHubで編集を提案

Discussion

melodyclue_routermelodyclue_router

2のtailwind-mergeは, classnamesでも代替できますか?

https://www.npmjs.com/package/classnames

nyatintenyatinte

代替はできません!
2で紹介したコードを用いて詳細に解説します

// 子コンポーネント
function MyGenericInput(props) {
    // propsをもとに、classNameを定義
    const className = `border rounded px-2 py-1 ${props.className || ''}`
    return <input {...props} className={className} />
}

// 親コンポーネント
function MySlightlyModifiedInput(props) {
    return (
        <MyGenericInput
            {...props}
            className="p-3" // ← paddingを3にしたい!
        />
    )
}

この場合、inputclassName

classNameの中身
border 
rounded 
px-2 
py-1 
+p-3

となります。一見意図したように動くように思いますが、p-3px-2, py-1があるため、反映されません。

意図したclassNameの中身
border 
rounded 
-px-2 
-py-1 
+p-3

このようなclassNameにならないといけないのです。tw-mergeはこの処理を行っています。
一方、classnamesclassNameの結合などを条件などに基づいて行うライブラリのため、この機能は提供されていません

そのため、代替ができません!

melodyclue_routermelodyclue_router

なるほど、競合ってやつですね?

今までclassnamestailwindcssと使っていたのですが、
これからはtw-mergeに切り替えようと思います!

kiyomizukiyomizu

classnames 以外の選択しないかなぁと調べてたらここにたどり着きました。
既存クラスの打消しは、classnamesでもやろうと思えばできた気がする。
ただ、呼び先のコンポーネントも打ち消せるように作る必要があるから tw-merge のほうが良さげですね

function MyComp(props) => 
<div classname={
  classnames(
    {border: true}, this.props.classnames
  )}>
ほげ
</div>

<MyComp classnames={{border: false}} />