Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

StorybookでReactコンポーネント分割の粒度を見極めよう

はじめまして!技術本部 Bill One Engineering Unit の杉崎と申します。技術ブログ初投稿なのでとても緊張しますね! 普段はフロントエンド成分強めの開発をしており、フロントエンドギルド(チームを横断して特定技術領域の改善やスキルアップをしていく取り組み)に所属しましたので、整理のためにも1本書いてみました。

なお、本記事はSansan Advent Calendar 2023の10日目の記事です。

はじめに

 フロントエンドの開発をするときに、UIを確認するためにローカルのURLにアクセスした経験はないでしょうか。

 もしバックエンドのAPIよりも先にUIを先行して実装している場合はどうでしょう。UIのためにAPIのモックを作ったり、直接 // TODOと記入して定数をAPIの戻り値として扱ったりしたでしょうか。そんな時にStorybookを使えば自由に値を渡したり、UIを見ながら開発できたりして大変便利です。

storybook.js.org

 Bill OneではStorybookを使ったVRTを導入しており、テストの面でも大いに役立っています。ご興味があれば是非こちらもご覧ください。

buildersbox.corp-sansan.com

 今回は視点を変えて、Storybookを軸に開発するとコンポーネント分割が自然にできるかもしれない話をご紹介します。

コンポーネント分割について

コンポーネントを分割する理由

 コンポーネントを分割することでアプリケーションとしての見通しがよくなり、保守性やパフォーマンスを良くできます。

 Reactはコンポーネントをツリー構造としてモデル化 します。 ツリー上の特定のコンポーネントでstateに変化が発生した場合は、そのコンポーネントと子孫に再レンダリングが発生します。そのため巨大コンポーネントに全てを詰め込むとパフォーマンスはその規模に応じて悪くなっていきます。

 また、コンポーネントの粒度を小さく設計し組み合わせる開発手法の1つとして、コンポーネント駆動開発があります。こちらはボトムアップ的なアプローチで、粒度の小さいコンポーネントから開発し、それを組み合わせることで画面を構築していきます。

 小さく作ることでチームでの開発の並列化や、成果物の再利用による開発生産性の向上、テスト・運用効率の向上など色々なメリットがあります。

 その結果、開発生産性などの指標を向上し、レビュー効率やデプロイ頻度を上げることにもつながるでしょう。

 そういった意味でも、適切にコンポーネントを分割し、小さな粒度で開発することはユーザー側、開発側双方にメリットがある重要な事柄と言えます。

どのように分割するか

 シンプルに考えるなら、単一責任の原則を念頭に置いてUIに関する概念はUIコンポーネントに、業務に関するロジックはCutomHookなどに責任を分担しながら切り出してみると良いと思います。

 前に書いた通り、Reactのコンポーネントはツリー構造で構成されるように管理しています、概念に従えばこのツリーで表現できるように設計をしていけば良さそうです。

 以下の図ではそれぞれの責任の範囲でコンポーネントを切り分けて、1つの商品アプリケーションを構成しています。 ここからもコンポーネントを適切な範囲で切り分けて、それをネストしてツリーを形成していることがわかります。

https://ja.react.dev/learn/thinking-in-react より抜粋。

Storybookを使ったアプローチ

 実際にはUIやロジックを責任や関心ごとに切り分けるといっても、どこに目安を置いて切り分けるかは裁量に委ねられています。

 そこで、方法の1つとしてStorybookを利用することで粒度をある程度見極めることができるのでは、という仮説を立ててみましょう。

モノリシックな状態から考える

以下のようなモノリシックなコンポーネントを例に考えてみます。

 これはユーザーが商品のリストから欲しい商品を選択し、カゴの中に選択した商品のリストと合計額を表示するアプリケーションです。1つのコンポーネントに、全商品リストの取得や、ユーザーによる商品の選択と表示に関する全ての情報が含まれています。業務上の制約として、既にカゴに入った商品は商品の選択リストから除外されます。

import { FC, useState } from "react"

type ProductId = number

export const OkaimonoComponent: FC = () => {
  const [stockedProductIdList, setStockedProductIdList] = useState<ProductId[]>([])

  const [currentProductId, setCurrentProductId] = useState<ProductId | undefined>(undefined)

  const productList = [
    { id: 1, category: "くだもの", price: 180, name: "りんご" },
    { id: 2, category: "くだもの", price: 460, name: "なし" },
    { id: 3, category: "くだもの", price: 2300, name: "すいか" },
    { id: 4, category: "やさい", price: 135, name: "きゅうり" },
    { id: 5, category: "やさい", price: 195, name: "きゃべつ" },
    { id: 6, category: "やさい", price: 98, name: "こまつな" },
  ] // get by API

  const availableProducts = productList.filter((product) => !stockedProductIdList.includes(product.id))

  const stockedProducts = productList.filter((product) => stockedProductIdList.includes(product.id))

  const totalAmount = stockedProducts.reduce((total, value) => total + value.price, 0)

  return (
    <>
      <div>商品リスト</div>
      {availableProducts.length > 0 ? (
        <form>
          <select
            onChange={(e) => {
              setCurrentProductId(Number(e.target.value))
            }}
          >
            <option>---</option>
            {availableProducts.map((product) => {
              return (
                <option key={product.id} value={product.id}>
                  {product.name}
                </option>
              )
            })}
          </select>
          <button
            type="button"
            style={{ padding: 0, background: "blue" }}
            onClick={() => {
              if (currentProductId !== undefined) {
                setStockedProductIdList([...stockedProductIdList, currentProductId])
              }
            }}
          >
            追加する
          </button>
        </form>
      ) : (
        <div>
          <p>選択可能な商品はありません</p>
          <button
            type="button"
            style={{ padding: 0, background: "red" }}
            onClick={() => {
              setStockedProductIdList([])
              setCurrentProductId(undefined)
            }}
          >
            すべてクリア
          </button>
        </div>
      )}
      <div>カゴの中の商品</div>
      <table>
        <thead>
          <tr>
            <th>名前</th>
            <th>価格</th>
            <th />
          </tr>
        </thead>
        <tbody>
          {stockedProducts.map((stocked) => {
            return (
              <tr key={stocked.id}>
                <td>{stocked.name}</td>
                <td>{stocked.price}</td>
                <td>
                  <button
                    style={{ padding: 0, background: "gray" }}
                    onClick={() => {
                      setStockedProductIdList(stockedProductIdList.filter((id) => id !== stocked.id))
                    }}
                  >
                    カゴに戻す
                  </button>
                </td>
              </tr>
            )
          })}
        </tbody>
      </table>
      <p>合計: {totalAmount}</p>
    </>
  )
}

 実際にはこのくらいのサイズ感であればまだコンポーネントは1つでもいいかもしれません。

 しかし、例えば検索機能、税込み金額を表示する機能、同じ商品を複数個表示する機能、割引機能、etc…など業務要件に応じて再現なくコード量が増えていけば、1つに収めるには大きすぎる状態になりそうです。なので早めに切り分けてしまいたいですね。さっそくツリー構造と責務を気にしながら、要素を分解してみましょう。

不自然なStoryを見つけて、分割を考える

 さて、トップレベルコンポーネントに当たるOkaimonoComponentをそのままStorybookに当てはめることはできるでしょうか。

import type { Meta, StoryObj } from "@storybook/react"

import { OkaimonoComponent } from "./OkaimonoComponent"

const meta = {
  title: "OkaimonoComponent",
  component: OkaimonoComponent,
} satisfies Meta<typeof OkaimonoComponent>

export default meta
type Story = StoryObj<typeof meta>

export const SelectedNoProduct: Story = {}

export const SelectedSomeProducts: Story = {
  args: {
    // ???
  },
}

 例えばSelectedNoProductのように、商品を何も選択していないケースにおいてはそのままUIが描画できているように思えます。しかし、他のパターンについて考える際はどうでしょう。

  1. 商品を選択した場合のUI
  2. 選択できる商品がなくなった場合のUI
  3. 商品APIが何も返さなかった場合のUI
  4. ボタンに関するUI

etc...

 UIの状態が複数あり、Storyに落とし込むとその組み合わせが複雑になりそうですね。 このままでは扱いづらく、責務もぼやけてしまいそうですね。このことからこのコンポーネントの粒度は大きいことがわかりました。冒頭で述べたようなツリー構造を考えながら、適切な粒度に分割してみましょう。

Storyを洗い出し、適切な分割粒度の目安をつける

トップレベルコンポーネントとなるOkaimonoComponentをそれっぽく2つの領域に分解してみましょう。

  • 商品選択に関するコンポーネント (AvailableProductListComponent)
  • カゴに関するコンポーネント(StockedProductListComponent)

まず前者の商品選択に関するコンポーネントについて考えてみます。

 ここでは選択可能な商品があればそのドロップダウンリストと商品追加ボタンを、そうでなければ商品がないメッセージとクリアボタンを表示します。

 商品選択には"全商品のリスト" 及び "カゴに入っていない商品が選択できる" という業務上の制約があります。実現にあたって、まず以下のパターンが考えてみましょう。

  1. 全商品のリストに要素がある場合
  2. 全商品のリストが空配列の場合
  3. カゴに入った商品idのリストに要素がある場合
  4. カゴに入った商品idのリストが空配列の場合

 さてこれはこのコンポーネントの責務として適切でしょうか。

 カゴに入った商品idのリストを、商品選択に関するコンポーネントで検証することに大きな違和感があります。ここはあくまで"商品を選択すること"に注力すべきです。

 また、全商品リストはカゴに関するコンポーネントでも使用する共通の情報源であり、このコンポーネントで取得する情報としては不適切です。

 従って、先に述べた通りこのコンポーネントでは「既にカゴに入った商品は何か」というところに関心はないため、選択可能な商品の絞り込みを親コンポーネントで行ったほうが良さそうです。

 上記を考慮すると、違和感なく表現できそうな単位として以下のような定義ができそうです。

  1. 選択可能な商品がある場合
  2. 選択可能な商品がない場合

 こうすることで、カゴに入ったリストの状況に関わりなく、"商品を選択すること" という責務に注力できます。

最適な状態

 結果的に、ここでは絞り込まれた商品のリストを親コンポーネントから渡すことが最適、という結論になりました。

 このように、Storyの構成上不自然な点がないかを先に考えることで、自然とこのコンポーネントで注力する責務を明らかにできましたね。

import type { Meta, StoryObj } from "@storybook/react"

import { AvailableProductListComponent } from "./AvailableProductListComponent.tsx"

const meta = {
  title: "AvailableProductListComponent",
  component: AvailableProductListComponent,
} satisfies Meta<typeof AvailableProductListComponent>

export default meta
type Story = StoryObj<typeof meta>

export const FullOfAvailableProduct: Story = {
  args: {
    availableProducts: [
      { id: 1, category: "くだもの", price: 180, name: "りんご" },
      { id: 2, category: "くだもの", price: 460, name: "なし" },
      { id: 3, category: "くだもの", price: 2300, name: "すいか" },
      { id: 4, category: "やさい", price: 135, name: "きゅうり" },
      { id: 5, category: "やさい", price: 195, name: "きゃべつ" },
      { id: 6, category: "やさい", price: 98, name: "こまつな" },
    ],
  },
}

export const NoAvailableProduct: Story = {
  args: {
    availableProducts: [],
  },
}

細かい粒度まで掘り下げる

 大きな責務を切り出すことができましたが、その中でもボタンをさらに切り出すことができそうです。クリアボタンや追加ボタンは同様のスタイルで構成されており、差分は色とメッセージに関する部分になります。

 つまりボタンは色とメッセージだけに関心を持てばいいので、以下のとおりにCommonButtonとして切り出せそうですね。

import type { Meta, StoryObj } from "@storybook/react"

import { CommonButton } from "./CommonButton.tsx"

const meta = {
  title: "CommonButton",
  component: CommonButton,
} satisfies Meta<typeof CommonButton>

export default meta
type Story = StoryObj<typeof meta>

export const AddButton: Story = {
  args: {
    message: "追加する",
    color: "blue",
  },
}

export const ClearButton: Story = {
  args: {
    message: "すべてクリア",
    color: "red",
  },
}

export const ReturnButton: Story = {
  args: {
    message: "商品を戻す",
    color: "gray",
  },
}

 Storybookで違和感がなくなる範囲まで切り出しを続けていくと、ツリーの末端に到達します。これはAtomicDesignにおけるAtomやMoleculeの粒度まで分解できたと言えます。

 実際の実装はこのようになります。長くなるのでここまでにしますが、booleanの中身、すなわちform等の粒度でさらに分解できそうですね。

import { FC, useState } from "react"
import { CommonButton } from "../molecules/CommonButton.tsx"

export type ProductId = number

export type Product = {
  id: ProductId
  category: string
  price: number
  name: string
}

export type AvailableProducts = Product[]

type AvailableProductListComponentProps = {
  availableProducts: AvailableProducts
  onAddProduct: (productId: ProductId) => void
  onClearProduct: () => void
}

export const AvailableProductListComponent: FC<AvailableProductListComponentProps> = ({
  availableProducts,
  onAddProduct,
  onClearProduct,
}) => {
  const [currentProductId, setCurrentProductId] = useState<ProductId | undefined>(undefined)

  return (
    <>
      <div>商品リスト</div>
      {availableProducts.length > 0 ? (
        <form>
          <select
            onChange={(e) => {
              setCurrentProductId(Number(e.target.value))
            }}
          >
            <option>---</option>
            {availableProducts.map((product) => {
              return (
                <option key={product.id} value={product.id}>
                  {product.name}
                </option>
              )
            })}
          </select>
          <CommonButton
            message="追加する"
            color="blue"
            onClick={() => {
              if (currentProductId !== undefined) {
                onAddProduct(currentProductId)
              }
            }}
          />
        </form>
      ) : (
        <div>
          <p>選択可能な商品はありません</p>
          <CommonButton
            message="すべてクリア"
            color="red"
            onClick={() => {
              onClearProduct()
              setCurrentProductId(undefined)
            }}
          />
        </div>
      )}
    </>
  )
}

同じように進めてみる

 後者のカゴに関するコンポーネント(StockedProductListComponent)についても同様に進められそうです。カゴに入った商品一覧についても、親コンポーネントに任せたほうが良さそうです。これも結局、選択可能なリストの絞り込みのロジックが関係してくるからです。

import type { Meta, StoryObj } from "@storybook/react"

import { StockedProductListComponent } from "./StockedProductListComponent.tsx"

const meta = {
  title: "StockedProductListComponent",
  component: StockedProductListComponent,
} satisfies Meta<typeof StockedProductListComponent>

export default meta
type Story = StoryObj<typeof meta>

export const StockedNoProduct: Story = {
  args: {
    stockedProducts: [],
  },
}

export const StockedSomeProducts: Story = {
  args: {
    stockedProducts: [
      { id: 1, category: "くだもの", price: 180, name: "りんご" },
      { id: 2, category: "くだもの", price: 460, name: "なし" },
      { id: 3, category: "くだもの", price: 2300, name: "すいか" },
    ],
  },
}

切り分けに迷うコンポーネント

 合計金額についてはロジックを親に持たせるか悩みそうです。しかし現時点での制約上、stockedProducts.priceの合計でしかなく、他コンポーネントへの依存もありません。カゴの商品として絞り込まれたリストのpriceの合計に応じて合計値が自動的に決まるので、このコンポーネントに入れてしまいましょう。

 現時点では金額を表示するという単一のUIの責務に注力すれば良さそうなので、ごく単純なStoryでカバーできそうです。

import type { Meta, StoryObj } from "@storybook/react"
import { TotalAmount } from "./TotalAmount.tsx"

const meta = {
  title: "TotalAmount",
  component: TotalAmount,
} satisfies Meta<typeof TotalAmount>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {
    totalAmount: 1000,
  },
}

 実装はこのような形になります。これもテーブルの中身を更に切り出すことができそうですね。

import { FC } from "react"

type ProductId = number

type Product = {
  id: ProductId
  category: string
  price: number
  name: string
}

type StockedProducts = Product[]

type StockedProductListComponentProps = {
  stockedProducts: StockedProducts
  onReturnProduct: (productId: ProductId) => void
}

export const StockedProductListComponent: FC<StockedProductListComponentProps> = ({
  stockedProducts,
  onReturnProduct,
}) => {
  const totalAmount = stockedProducts.reduce((total, value) => total + value.price, 0)

  return (
    <>
      <div>カゴの中の商品</div>
      <table>
        <thead>
          <tr>
            <th>名前</th>
            <th>価格</th>
            <th />
          </tr>
        </thead>
        <tbody>
          {stockedProducts.map((stocked) => {
            return (
              <tr key={stocked.id}>
                <td>{stocked.name}</td>
                <td>{stocked.price}</td>
                <td>
                  <button
                    style={{ padding: 0, background: "gray" }}
                    onClick={() => {
                      onReturnProduct(stocked.id)
                    }}
                  >
                    カゴに戻す
                  </button>
                </td>
              </tr>
            )
          })}
        </tbody>
      </table>
      <p>合計: {totalAmount}</p>
    </>
  )
}

業務ロジックを切り出す

さて、ツリー上のコンポーネントをすべて分割できました。 最後に、親コンポーネントに残ったものが業務ロジックのリストになるので、これをCustomHookに切り出してしまえば完成です。

import { useState } from "react"

type UseProductsResult = {
  availableProducts: AvailableProducts
  stockedProducts: StockedProducts
  handleAddProduct: (productId: ProductId) => void
  handleReturnProduct: (productId: ProductId) => void
  handleClearProduct: () => void
}

export type ProductId = number

export type Product = {
  id: ProductId
  category: string
  price: number
  name: string
}

export type AvailableProducts = Product[]

export type StockedProducts = Product[]

export const useProducts = (): UseProductsResult => {
  const [stockedProductIdList, setStockedProductIdList] = useState<ProductId[]>([])

  const productList: Product[] = [
    { id: 1, category: "くだもの", price: 180, name: "りんご" },
    { id: 2, category: "くだもの", price: 460, name: "なし" },
    { id: 3, category: "くだもの", price: 2300, name: "すいか" },
    { id: 4, category: "やさい", price: 135, name: "きゅうり" },
    { id: 5, category: "やさい", price: 195, name: "きゃべつ" },
    { id: 6, category: "やさい", price: 98, name: "こまつな" },
  ] // get by API

  const availableProducts: AvailableProducts = productList.filter(
    (product) => !stockedProductIdList.includes(product.id),
  )

  const stockedProducts: StockedProducts = productList.filter((product) => stockedProductIdList.includes(product.id))

  const handleAddProduct = (productId: ProductId) => {
    setStockedProductIdList([...stockedProductIdList, productId])
  }

  const handleReturnProduct = (productId: ProductId) => {
    setStockedProductIdList(stockedProductIdList.filter((id) => id !== productId))
  }

  const handleClearProduct = () => {
    setStockedProductIdList([])
  }

  return {
    availableProducts,
    stockedProducts,
    handleAddProduct,
    handleReturnProduct,
    handleClearProduct,
  }
}

 OkaimonoComponentの最終的な実装です。

import { FC } from "react"
import { AvailableProductListComponent } from "./AvailableProductList/AvailableProductListComponent.tsx"
import { StockedProductListComponent } from "./StockedProductList/StockedProductListComponent.tsx"
import { useProducts } from "./useProducts.ts"

export const OkaimonoComponent: FC = () => {
  const { availableProducts, stockedProducts, handleAddProduct, handleClearProduct, handleReturnProduct } =
    useProducts()

  return (
    <>
      <AvailableProductListComponent
        availableProducts={availableProducts}
        onAddProduct={handleAddProduct}
        onClearProduct={handleClearProduct}
      />
      <StockedProductListComponent stockedProducts={stockedProducts} onReturnProduct={handleReturnProduct} />
    </>
  )
}

完成形

 前半で述べたツリーを思い出してみてください、コンポーネントを分割したことによって、それなりにいい感じの粒度でのツリー構造を形成できたのではないでしょうか。 このアプリケーションは分割(説明)を省略した黄色で表示した部分を含めると以下のような構成になりました。

 参考までに、実際の画面は以下のとおりです。

さいごに

長くなりましたが、Storybookを活用したスムーズな分割アプローチのイメージの1つとして何かのお役に立てれば幸いです。

© Sansan, Inc.