🙅‍♂️

UIコンポーネントにドメインを入れるな

2022/11/15に公開約6,700字3件のコメント

UIコンポーネントにUI以外の知識、すなわちデータロジック等のドメイン知識を入れないで欲しいという話をします。

UIコンポーネントの定義

ここで「UIコンポーネント」は、UIデザインのみを表した汎用的に使い回せるコンポーネントであると定義します。

例えばMUIやChakra UIにあるものはUIコンポーネントであり、それらを使って「ユーザリスト」「特別CTA用ボタン」という特定の機能を作った場合はUIコンポーネントではないという前提です。(言葉の使い方として合っているだろうか微妙ですが、ニュアンスで何となく掴んでください...)

抽象化を間違う例

ここから(実話ベースですが)仮のお話で例えます。学生のデータを何でも管理する万能ダッシュボードの実装にアサインされたフロントエンドエンジニアA君が居ました。デザインを確認しにFigmaを開くと、このように単一の数値を見せるUIが共通しているようです。

A君は初めにカロリー表示機能に取り掛かります。APIのデータモデルは既にあるので、

  • データを受け入れる
  • それらをデザインに合わせてスタイリングする

と考えて進めれば無事に完了しました。

type Calorie = {
  category: string
  number: string
}

type CalorieStatProps = {
  calorie: Calorie
}

function CalorieStat(props: CalorieStatProps) {
  return (
    <dl className="inline-block w-36 rounded-md border border-solid border-gray-200 p-4">
      <dt className="text-xs text-gray-400">{props.calorie.category}</dt>
      <dl className="mt-2 text-2xl font-bold">
        {props.calorie.number}
        <span className="ml-1 text-sm">kcal</span>
      </dl>
    </dl>
  )
}
<CalorieStat calorie={{ category: '消費カロリー', number: '1,234' }} />

次に成績表示も同様の流れで実装しました。やや直感的じゃないなと感じつつクリア。

type Grade = {
  subject: string
  term: number
  score: number
  full_score: number
}

type GradeStatProps = {
  grade: Grade
}

function GradeStat(props: GradeStatProps) {
  return (
    <dl className="inline-block w-36 rounded-md border border-solid border-gray-200 p-4">
      <dt className="text-xs text-gray-400">
        {props.grade.subject} ({props.grade.term}学期)
      </dt>
      <dl className="mt-2 text-2xl font-bold">
        {props.grade.score}
        <span className="ml-1 text-sm">点 / {props.grade.full_score}</span>
      </dl>
    </dl>
  )
}
<GradeStat
  grade={{ subject: '国語', term: 1, score: 85, full_score: 100 }}
/>

A君は、今度は生徒の身長・年齢・体重をまた同じデザインで実装するように伝えられます。もう何度同じコードを書かなければいけないのかと思うとうんざりします...。また、新規に入ったB君も同じ愚痴を言っていました。

一体何が問題だったのかというと、地獄の門は最初の方針で既に開いてしまっていました。

抽象化が正しい例

前章における問題はデータの確認から一直線に突っ走って表示さえ良ければOKという姿勢です。
システムの根幹となるUIがあるとき、それを抽象化しないと設計を怠ったも同然であり、バックエンドでDB接続からAPI出力までを一纏めに書く行為と同じくらい重大です。(たまにそれをやりたくなる場合もありますが...)

つまり、次のようなコンポーネントになるでしょう。

type StatProps = {
  label: React.ReactNode
  value: React.ReactNode
  unit: React.ReactNode
}

function Stat(props: StatProps) {
  return (
    <dl className="inline-block w-36 rounded-md border border-solid border-gray-200 p-4">
      <dt className="text-xs text-gray-400">{props.label}</dt>
      <dl className="mt-2 text-2xl font-bold">
        {props.value}
        <span className="ml-1 text-sm">{props.unit}</span>
      </dl>
    </dl>
  )
}
<Stat label="消費カロリー" value="1,234" unit="kcal" />
<Stat label="国語 (1学期)" value={85} unit="点 / 100" />

その上で更にデータモデルのマッピングを抽象化したい場合は上で実装したものを呼び出します。こうすることでコードが何に依存しているかが正しく目に見えるようになり改善されます。

type CalorieStatProps = {
  calorie: Calorie
}

function CalorieStat(props: CalorieStatProps) {
  return (
    <StatRoot>
      <StatLabel>{props.calorie.category}</StatLabel>
      <StatValue>
        {props.calorie.number}
        <StatUnit>kcal</StatUnit>
      </StatValue>
    </StatRoot>
  )
}
<CalorieStat calorie={{ category: '消費カロリー', number: '1,234' }} />

実装タイプ別: モノリシックvs合成

先ほど実装したStatコンポーネントは拡張性に乏しいデメリットがあります。もし数字だけの色を変えたい、アクセントカラーのバリエーションを作って欲しい、数字をリンクにしたい、ラベル無しVerが欲しいetc...という変化に対応するときは内部実装に手を加えるのが億劫です。

ここでUIコンポーネントの実装は2タイプに分けられると思っています。簡単だが拡張性に乏しい「モノリシックタイプ」と、やや手間だが拡張性に優れた「合成タイプ」です。

モノリシックは既に実装したので合成のパターンを次に示します。(長いのでアコーディオンで)

コンポーネント
// ----------------------------------

type StatRootProps = React.ComponentPropsWithRef<'dl'>

type StatRootRef = HTMLDListElement

const StatRoot = React.forwardRef<StatRootRef, StatRootProps>((props, ref) => {
  const { className, ...rest } = props

  return (
    <dl
      className={clsx(
        'inline-block w-36 rounded-md border border-solid border-gray-200 p-4',
        className
      )}
      {...rest}
      ref={ref}
    />
  )
})

StatRoot.displayName = 'StatRoot'

// ----------------------------------

type StatLabelProps = React.ComponentPropsWithRef<'dt'>

type StatLabelRef = HTMLElement

const StatLabel = React.forwardRef<StatLabelRef, StatLabelProps>(
  (props, ref) => {
    const { className, ...rest } = props

    return (
      <dt
        className={clsx('text-xs text-gray-400', className)}
        {...rest}
        ref={ref}
      />
    )
  }
)

StatLabel.displayName = 'StatLabel'

// ----------------------------------

type StatValueProps = React.ComponentPropsWithRef<'dd'>

type StatValueRef = HTMLElement

const StatValue = React.forwardRef<StatValueRef, StatValueProps>(
  (props, ref) => {
    const { className, ...rest } = props

    return (
      <dd
        className={clsx('mt-2 text-2xl font-bold', className)}
        {...rest}
        ref={ref}
      />
    )
  }
)

StatValue.displayName = 'StatValue'

// ----------------------------------

type StatUnitProps = React.ComponentPropsWithRef<'span'>

type StatUnitRef = HTMLSpanElement

const StatUnit = React.forwardRef<StatUnitRef, StatUnitProps>((props, ref) => {
  const { className, ...rest } = props

  return (
    <span className={clsx('ml-1 text-sm', className)} {...rest} ref={ref} />
  )
})

StatUnit.displayName = 'StatUnit'
利用側
<StatRoot>
  <StatLabel>消費カロリー</StatLabel>
  <StatValue>
    1,234
    <StatUnit>kcal</StatUnit>
  </StatValue>
</StatRoot>

合成タイプのポイントは以下です。この方針を守ることで、やや実装は長くなりますが堅牢性にも柔軟性にも優れたコンポーネントが出来上がります。

  • マークアップ構造を可視化し、より単一責務にする
  • スタイルだけ受け継いだHTMLを組み立てて作る

補足

Context

実はこのままでは「StatLabelがStatRootの外側に置けてしまう」という問題があり、これにはuseContextAPIで対応できます。今回は説明を省きますが、ライブラリレベルの堅牢性を持たせるなら有用です。

名前空間

以下のように特定の名前空間を持たせる方法も大変有名で、かつ個人的に好きです。しかし、メリット/デメリットや実装パターンにバリエーションがあることから、ここでは説明は省きます。

<Stat.Root>
  <Stat.Label>消費カロリー</Stat.Label>
  <Stat.Value>
    1,234
    <Stat.Unit>kcal</Stat.Unit>
  </Stat.Value>
</Stat.Root>

Styled API

前に出てきた「スタイルだけ受け継いだHTML」をすごく簡単に作れる方法...が実はあります。Styled components, Emotionの一部, Stitchesに見られるStyled APIです。

このAPIはUIコンポーネントライブラリ開発には特化レベルに向いていますが、当然ながらWeb開発の全てがUIコンポーネントの量産である訳ではありません。Styled系には独特の癖があるので、功罪をよく吟味してからプロジェクトに採用してください。

参考記事はこちら:
https://zenn.dev/yhase_rqp/articles/db63567117c110

常にこうするべき?

程度によります。

  • UIを抽出する価値が薄いなら、UIコンポーネントは作らなくていいです。
  • UIを抽出する価値がそこそあるなら、できれば合成タイプでUIコンポーネントを実装してください。
  • UIを抽出することが重要なタスクの場合は、極力合成タイプでUIコンポーネントを実装してください。

Discussion

記事の本題と少しずれてしまうかもしれないですが...、

実はこのままでは「StatLabelがStatRootの外側に置けてしまう」という問題があり、これにはuseContextAPIで対応できます。

こちらってtype error等で検知できる形になるのでしょうか?

こちらってtype error等で検知できる形になるのでしょうか?

Reactの機能で検知します。変更例のサンプルコード以下のようになります。(StatValue等の他の部分でも同様です)

StatRoot

StatLabel

呼び出し側


Context APIについては下記リンク等などがまとまっていておすすめです。こちらで話すと長くなってしまうので是非参考にしてください。
https://ja.reactjs.org/docs/context.html
https://ja.reactjs.org/docs/hooks-reference.html#usecontext
https://react-typescript-cheatsheet.netlify.app/docs/basic/getting-started/context/

なるほど!独自でランタイム時にエラーを吐くようにしてるのですね!
とてもイメージ湧きましたありがとうございます!

ログインするとコメントできます