TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

UIコンポーネントが独り立ちできる環境を

初めに

フロントエンドエンジニアとして、はや1年半が経ちました。 プロジェクトには途中から参加した身であり、当初から開発しやすい環境はかなり整っておりました。
今回は、そこで初めて触れた、開発しやすい環境の要因の一つである Storybook についてお話ししたいと思います。

岸本 和哉(キシモト カズヤ)
今年で新卒4年目のフロントエンドエンジニア。アクアリウムや映画が好きです。
画像は6年前に模写したものです。


全体の流れです。 まず、Storybook の簡単な概要と導入を説明し、次に実際の記述方法について説明します。ここでは、Storybook v6.5 の記述を用います。 そして、2023/03のリリース(Storybook v7.0)での最新の記述方法について説明します。

Storybookとは?

そもそも Storybook とは?となる方もおられるかもしれません。
公式サイトにはこう記載されていました。

「UI コンポーネントとページを切り離し、独立した状態でコンポーネントの開発を行うことができるオープンソースツールです。」

「アプリケーションを動かさずとも、コンポーネント単体で実際の表示の確認ができる」と言うと、イメージがつきやすいのではないかと思います。 後からプロジェクトに参加した自分には、必要としているパーツを、実際の表示から確認することにより容易に探すことができて、「同じようなパーツを作成するリスクを減らすことができる点」が大きなメリットとして感じられました。

そんな Storybook は、色々なフレームワーク向けのバージョンが用意されていますが、今回は React 用に触れていきます。

導入

今回は簡単な input タグ とボタンを組み合わせたコンポーネントを例に話を進めていきたいと思います。
※ node および npm がインストールされている環境を前提にしています。(私は node 18.13.0、npm 8.19.3 で確認しました)

と、その前に下準備です。

Create-React-Appを参考に、TypeScript を使用できる React のアプリを作成後、サンプルのコンポーネントでは emotion を使用しますので、以下のコマンドで emotion を使用できるようにしてください。

# npm
npm i @emotion/react

# yarn
yarn add @emotion/react

Storybook はとても簡単に導入&実行することができます。

React プロジェクトのルートディレクトリにて以下を実行することで、Storybook がインストールされます(2023/4/11 時点だと v7.0.2 になります)。

npx storybook init

続いて、Storybook の実行は以下です。

# npm
npm run storybook

# yarn
yarn storybook

次にサンプルのコンポーネントを作成します。 input タグとボタンの組み合わせで、propsonClick で動きをつけることを想定したコンポーネントです。 設置場所は、src の下に parts ディレクトリを作成し、入れてください。 以下がそのコードです。tsx ファイルで作成します。

//InputTextWithButton.tsx

import { css } from "@emotion/react";
import styled from "@emotion/styled";

type InputTextWithButtonProps = {
  label: string;
  inputValue: string;
  color: "DEFAULT" | "RED";
  onClick: () => void;
};

export const InputTextWithButton: React.FC<InputTextWithButtonProps> = ({
  label,
  inputValue,
  color,
  onClick,
}) => {
  return (
    <Wrapper>
      <Input color={color} value={inputValue} readOnly />
      <Button type="button" color={color} onClick={onClick}>
        {label}
      </Button>
    </Wrapper>
  );
};

const Wrapper = styled.div`
  display: flex;
  align-items: center;
`;

const Input = styled.input<Pick<InputTextWithButtonProps, "color">>`
  margin-right: 8px;
  color: #585a5f;
  padding: 6px 8px;
  font-size: 14px;
  background: #ffffff;
  border: 1px solid #bcc4c8;
  border-radius: 4px;
  line-height: 1.5;
  font-size: 13px;
  ${({ color }) =>
    color === "RED" &&
    css`
      color: #e74c3c !important;
    `};
`;

const Button = styled.button<Pick<InputTextWithButtonProps, "color">>`
  padding: 10px 12px;
  font-weight: bold;
  font-size: 15px;
  border: 1px solid #bcc4c8;
  border-radius: 4px;
  text-align: center;
  white-space: nowrap;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  transition: backgroundColor 0.18s ease-in-out;
  user-select: none;
  background-color: #5bc0de;
  color: #2c3e50;
  ${({ color }) =>
    color === "RED" &&
    css`
      background-color: #e74c3c;
      color: #ffffff;
      border: 1px solid #bcc4c8;
    `};
`;

Storybook(v6.5 / CSF2)の書き方

先程は導入方法を説明しました。が、このままだと作ったコンポーネントを Storybook で確認することはできません。 (実行した際に画面にすでに何か表示されていますが、それは Storybook 側で用意されているものです)

Storybook 上に自分の作成したコンポーネントを表示するために、Story ファイルを書く必要があります。 Story ファイルは、Storybook に認識してもらう部分と表示したいコンポーネントを記述したファイルを示します。

なお、バージョンによって記述方法が変わります。(後ほど記載します) ここから先は、Storybook v6.5 (今携わっているフォームのプロジェクトで 2023/4/11 時点で利用しているバージョン), CSF2 の書き方になります。 CSFとは、「Component Story Format」の略称です。

前置きが長くなりましたが、ここから Story ファイルの作成に入ります。 Story ファイルのファイル名はコンポーネント名と合わせて InputTextWithButton.stories.tsx とします。

# 元ファイル名
InputTextWithButton.tsx

# storybook用ファイル名
InputTextWithButton.stories.tsx

ファイル名はこれでいきましょう。

ここからは、中を記述していきます。 公式ページによると以下の記載がありました。

Storybook にコンポーネントを認識させるには、以下の内容を含む default export を記述します

  • component : コンポーネント自体。
  • title : Storybook のサイドバーにあるコンポーネントを参照する方法。

なるほど、認識してもらうには必須の項目があるようです。 具体的には、 componenttitle を含んだ、 export default が必要となってくるようです。

では、コンポーネントはボタンを使用したいので InputTextWithButton にし、titleparts/InputTextWithButton で表示させようと思います。

export default {
  component: InputTextWithButton,
  title: "parts/InputTextWithButton",
} as Meta;

これで、Storybook はこのファイルを認識してくれることでしょう。

次に、Storyの共通部分となる JSX の部分を Template として定義します。

const Template: Story<typeof InputTextWithButton> = (args) => {
  const fruits = ["apple", "pine", "banana", "orange"];
  const [inputValue, setInputValue] = useState("ランダムで表示されます");
  const onClick = () => {
    setInputValue(fruits[Math.floor(Math.random() * fruits.length)]);
  };
  return (
    <InputTextWithButton
      label={args.label}
      inputValue={inputValue}
      onClick={onClick}
      color={args.color}
    />
  );
};

引数はサンプルコードに記載されている分、用意してください。 今回のケースだと、label など4つ必要になります。

ここでは、useState のようなReactフックの使用も可能です。 onClick では、ボタンをクリックするたびに input タグのテキストが切り替わる動きをつけています。

通常のコンポーネントのように引数を指定してあげます。 引数(ボタン名やボタンのIDなど)である args は、この次に設定します。

export const Default = Template.bind({});
Default.args = {
  label: "ランダムボタン",
  color: "DEFAULT",
};

ここは Story と呼ばれる部分です。 Storybook で表示される名称や、引数を指定する箇所になります。

今回は、通常時の見た目を表示したいので、デフォルトの状態を用意します。 名称を Default にし、bind を行い、引数もそれぞれ用意します。

このように Storybook で見ることができます。

同じ要領で赤色のボタンも表示しましょう。 Storybook は、エラーや正常など状態ごとに表示ができます。

export const Red = Template.bind({});
Red.args = {
  label: "ランダムボタン赤",
  color: "RED",
};

Default の引数を引き継いで、labelcolor を変えました。
RED と指定したので、ボタンの色や文字色が変わります。

こうして状態ごとに切り分けて、Storybook で表示させることができます。

はい、これでボタンの通常色と赤色を確認できるようになりました。 ということで、完成です。意外にも簡単でしたね。 下記が Story ファイルの全体像になります。

//InputTextWithButton.stories.tsx

import { Meta, Story } from "@storybook/react";
import { useState } from "react";
import { InputTextWithButton } from "./InputTextWithButton";

export default {
  component: InputTextWithButton,
  title: "parts/InputTextWithButton",
} as Meta;

const Template: Story<typeof InputTextWithButton> = (args) => {
  const fruits = ["apple", "pine", "banana", "orange"];
  const [inputValue, setInputValue] = useState("ランダムで表示されます");
  const onClick = () => {
    setInputValue(fruits[Math.floor(Math.random() * fruits.length)]);
  };
  return (
    <InputTextWithButton
      label={args.label}
      inputValue={inputValue}
      onClick={onClick}
      color={args.color}
    />
  );
};

export const Default = Template.bind({});
Default.args = {
  label: "ランダムボタン",
  color: "DEFAULT",
};

export const Red = Template.bind({});
Red.args = {
  label: "ランダムボタン赤",
  color: "RED",
};

サクッとまとめますと、大まかな作業としては3つに分けられます。

  • export default で Storybook に認識させる。
  • 表示させたい JSX を記述する。 React フックの使用なども可能。
  • 引数を設定する。状態は何個でも追加可能。

このような流れです。 Storybook v6.5 / CSF2 のざっくりとした書き方は以上になります。

Storybook(v7.0 / CSF3)の書き方

Storybook v7.0 からは CSF3 の書き方が、デフォルトとなります。

CSF3 での書き方も今回触ってみたので、ダイジェストで記載します。

先ほどの Story ファイルを CSF3 で書き換えたものが以下の内容となります。 少し顔つきが変わりました。

//InputTextWithButton.stories.tsx

import { InputTextWithButton } from "./InputTextWithButton";
import { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";

export default {
  component: InputTextWithButton,
  title: "parts/InputTextWithButton",
  args: {
    label: "ランダムボタン",
  },
  decorators: [
    (_InputTextWithButton, context) => {
      const fruits = ["apple", "pine", "banana", "orange"];
      const [inputValue, setInputValue] = useState("ランダムで表示されます");
      const onClick = () => {
        setInputValue(fruits[Math.floor(Math.random() * fruits.length)]);
      };
      return (
        <_InputTextWithButton args={{ ...context.args, onClick, inputValue }} />
      );
    },
  ],
} as Meta<typeof InputTextWithButton>;

type Template = StoryObj<typeof InputTextWithButton>;

export const Default: Template = {
  args: {
    color: "DEFAULT",
  },
};

export const Red: Template = {
  args: {
    label: "ランダムボタン赤",
    color: "RED",
  },
};

export default は、CSF3 でも必要となります。

今回は指定していますが、title は任意で指定することとなります。 省略した場合は、ディレクトリのパスが利用されます。

args はここにも記載することができます。 ここにはデフォルトで利用される args を指定します。

また decorators を利用することで、コンポーネントに追加でスタイルを当てたり、Story 内 で React フックを利用することができます。 decorators の第一引数はコンポーネント、第二引数は Story 上のコンテクストオブジェクトとなっており、Storyの args を第二引数で参照することができます。
(コンテクストオブジェクトの説明はこちら

export default {
  component: InputTextWithButton,
  title: "parts/InputTextWithButton",
  args: {
    label: "ランダムボタン",
  },
  decorators: [
    (_InputTextWithButton, context) => {
      const fruits = ["apple", "pine", "banana", "orange"];
      const [inputValue, setInputValue] = useState("ランダムで表示されます");
      const onClick = () => {
        setInputValue(fruits[Math.floor(Math.random() * fruits.length)]);
      };
      return (
        <_InputTextWithButton args={{ ...context.args, onClick, inputValue }} />
      );
    },
  ],
} as Meta<typeof InputTextWithButton>;

最後に Story を記述します。 bind は必要なくなり、記述方法はオブジェクトとなりました。

export const Default: Template = {
  args: {
    color: "DEFAULT",
  },
};

export const Red: Template = {
  args: {
    label: "ランダムボタン赤",
    color: "RED",
  },
};

ここでも、decoratorsargs の使用が可能です。 もし args などが必要なければ、空のオブジェクトを渡すだけで表示されます。

export const Default: Template = {};

赤色のボタン( Red )でargs を使用した値の上書きを行なっています。 必要な箇所のみを後から変更することで、コードがかなり短くなりました。

また、似たような Story を使用する際は、スプレッド構文を使うことで、args などを引き継ぐことが可能です。

export const DifferentLabel = {
  ...Default,
  args: {
    label: "ボタン",
  },
};

Story の部分は bind からオブジェクトになり、書きやすくなったと感じました。

今回は実業務でも恩恵を受けた、Storybook について紹介しました。 フロントエンドの作業をしていて、「実際に表示されるものを見たい」とモヤモヤすることがあるのでよく使用しています。

Storybook はフロントエンドの作業における良い補助輪になると思いますので、気になった方は是非とも触ってみてください。
ありがとうございました!

シナジーマーケティング株式会社では一緒に働く仲間を募集しています。