スタイルを上書きできるコンポーネントはなるべく作らない方がいいという話

# はじめに
この記事はHRBrain Advent Calendar 2022カレンダー2の6日目の記事です。

https://qiita.com/advent-calendar/2022/hrbrain

こんにちは、フロントエンドエンジニアの村崎です。
Amazonのブラックフライデーでルンバを買いました。

この記事では、スタイルを上書きできるコンポーネントの説明と、なぜなるべく作らない方がいいのかを書きます。

# 前提
この記事中でのスタイリングには[styled-components](https://styled-components.com/)を用います。
CSS in JSを前提としますが、別ライブラリやCSS modulesによるスタイリングでも共通項があると思います。

# スタイルを上書きできるコンポーネントとは？
styled-componentsでは以下のようにすることで、スタイルの上書きを許容するコンポーネントを作ることができます。
`className`をpropから渡せるようにすると、`styled-components`がstyleを注入できるようになりoverrideができるようになります。[^1]


[^1]: https://styled-components.com/docs/basics#styling-any-component

marginをコンポーネント自身に持たせたりせず、呼び出し側で管理したい場合などによくこのように実装されているのを見かけます。

```tsx
import styled from "styled-components"
// 親コンポーネント.tsx
const ParentPage = () => {
    return (
        <div>
            {...}
            <Component />
        </div>
    )
}

const Component = styled(AnyComponent)`
    margin-top: 16px;
`

// 子コンポーネント.tsx
type Props = {
    className?: string
}
const AnyComponent = (props: Props) => {
    return <div className={props.className}>{...}</div>
}
```


# なぜ作らないほうがいいのか
margin以外にもあらゆるCSSが書けてしまうからです。

上記の例ではmargin-topを指定しました。
一見良さそうに見えますが、例えば以下のような子結合子を用いたセレクタによるoverrideを見てみます。

```tsx
const Wrap = styled(AnyComponent)`
    margin-top: 16px;
    & > div:first-child {
        text-align: center;
    }
`
```

コンポーネントのがわだけではなく、中身のスタイルまで変更されてしまいました。
元の`AnyComponent`は自身がどこで使われているかを知り得ないため、コンポーネントの改修などで`div`ではなく`span`などに変える事がある場合、知らぬ所でスタイルが崩れます...。

さらに、`AnyComponent`が必ずしもトップレベルの要素に`className`を渡しているとは限りません。
なんかよくわからないけど消したら表示が崩れるかも...というCSSの完成です。


実際、親要素で余白を付与したいためだけに`className`を受け取るようにしたのに、別の画面でも再利用され、その際にかなりスタイルをいじられてしまった...という場面に遭遇することがあります。

じゃあ`className`なんて受け取らずに`margin`を指定するpropにしたらいいじゃん！と思うかもしれませんが、全コンポーネントにそんなpropを生やすなんてことはしたくありません。

# ではどうするか
## スタイルに変化があるコンポーネントはpropsとして振る舞いを定義する
[Polaris](https://polaris.shopify.com/)などのデザインシステムのコンポーネントが参考になります。
コンポーネントのインターフェイスとして、スタイルの変化を制御させることで再利用性や保守性を高めることができます。

## 親要素でのmarginの付与にはレイアウトコンポーネントを使う
レイアウトコンポーネントとは、「パーツのレイアウトや間の余白」に責務を持つコンポーネントのことです。

[ChakraUI](https://chakra-ui.com/)や[Mantine](https://mantine.dev/)といったUIライブラリでこの責務を持つコンポーネント達がレイアウトとグルーピングされているため、レイアウトコンポーネントとこの記事では呼ぶことにします。

これらのライブラリでは、`Box`や`Flex`、`Stack`といったレイアウトコンポーネントを提供しています。
大変使い勝手が良いですが、これらのためにUIライブラリの導入はできない場合があります。
そのため、自前で実装してみます。


### 簡易的な実装例

```tsx
import styled from "styled-components"

type StyleProps = {
    gap?: 4 | 8 | 12 | 16 | 24
    between?: boolean
    wrap?: boolean
}

const Wrap = styled.div<StyleProps>`
    display: flex;
    align-items: center;
    ${(props) => props.gap && `gap: ${props.gap}px`};
    ${(props) => props.between && `justify-content: space-between`};
    ${(props) => props.wrap && `flex-wrap: wrap`};
`

type Props = {
    children?: ReactNode
} & StyleProps

export const Stack = (props: Props) => {
    return (
        <Wrap
            gap={props.gap}
            between={props.between}
            grow={props.grow}
        >
            {props.children}
        </Wrap>
    )
}
```

### サンプル

<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="ZERjRZw" data-user="murasaki-1102" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/murasaki-1102/pen/ZERjRZw">
  Untitled</a> by Murasaki-1102 (<a href="https://codepen.io/murasaki-1102">@murasaki-1102</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

#### レイアウトコンポーネントを使いやすくするためのtips
##### 必要最低限のpropsにする
classNameを継承させないことにより、propsからでしかスタイルの変更ができないようにしています。
これにより、限られたパターンでのスタイリングを制限できます。

##### プロパティの値をユースケースで縛る
例えばgapの値はデザインシステムなどの値に合わせnumberのリテラル型にすることで、型レベルで使用を制限できます。
`flex-wrap`のようなプロパティは`wrap-reverse`のユースケースがない場合、trueなら`wrap`といったbooleanの型にしてしまうことでシンプルになり、他のプロパティとの衝突を避けることができます。

##### as propを用いてelementを指定できるようにする
サンプルでは実装していませんが、例えば`li`要素でflexなどをしたかった場合に、`as="li"`のようなas propを用いるとdomのネストが一段低くなります。[^2]

[^2]: https://styled-components.com/docs/basics#extending-styles

### レイアウトコンポーネントのメリットとデメリット

#### メリット

##### CSSを書かなくてよくなる
CSSの事を意識することなく、渡すpropsのことだけ考えればよくなります。
これは初学者やバックエンドエンジニアの方が多くフロントエンドを触るようなチームで有効です。
また、記述量が少なくなるため開発スピードも上がります。

##### スタイルの統一
サンプルではflexとgapで要素間の余白を実現しました。
このコンポーネントを使うだけで、意図しないgridやmarginの付与を防ぐことができます。治安が良い。
レビューもコンポーネントと渡しているpropsを見るだけなのでコストが低いです。


#### デメリット
##### 要素が増える
divタグが増えることによるパフォーマンス懸念があります。これが最大のデメリットであり好き嫌いが別れる部分だと思います。

こちらはuhyoさんの記事の計測が参考になります。

https://zenn.dev/uhyo/articles/usememo-time-cost

DOMレベルでのパフォーマンスチューニングが必要なプロジェクトではレイアウトコンポーネントは採用できない可能性があります。

##### プロジェクトが大きくなると変更が大変
どのコンポーネントにも言えることですが、使われる箇所が多いほど影響範囲が大きくなります。
変更を感知するために自動テストの仕組みが必要になるでしょう。

# 終わりに
できるだけスタイルの上書きが可能なコンポーネントを作らないことでCSSの治安を守ることができます。

弊社HRBrainではコンポーネント設計やスタイリングの議論を共にできる仲間を募集しています。
興味を持った方がいればぜひこちらからご応募ください！

https://www.hrbrain.co.jp/recruit