ReactでStorybook チュートリアルをやってみました。

## はじめに
[ｋ．ｓ．ロジャース](https://www.wantedly.com/companies/ks-rogers)の藤本です。

今回は公式の[チュートリアル](https://www.learnstorybook.com/react/en/get-started
)をやってみました。

その前に「そもそもstorybookってなんだろう」という状態からだったので
いろんな方の記事を参考にしてみました。

僕なりにまとめると、
```サンドボックス環境を作り、
その中でコンポーネントの動作確認ができ
かつスタイルガイドとしても使える便利なツール```

と解釈しました。
（間違えてたらすいません）

色んな記事を見てるとデザイナーさんとの認識合わせができることについて触れられている気がしました。

## インストール

```bash
npx create-react-app taskbox
cd taskbox
npx -p @storybook/cli sb init
```

## 実行

```bash 
npm run storybook
```

## 初期画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/7f7c6838-6a0f-7dba-d154-f223beb17169.png)


## Simple component

以下のコンパイル済みCSSを`src/index.css`に格納
https://github.com/chromaui/learnstorybook-code/blob/master/src/index.css

`public/`に`font`と`icon`を格納
https://github.com/chromaui/learnstorybook-code/tree/master/public


`stories.js`を読み込めるように修正

``` .storybook/config.js
import { configure } from '@storybook/react';
import '../src/index.css';

const req = require.context('../src', true, /\.stories.js$/);

function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);
```

`Task.js`と`Task.stories.js`を作成

```React:src/components/Task.js
import React from 'react';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  return (
    <div className="list-item">
      <input type="text" value={title} readOnly={true} />
    </div>
  );
}
```

```React:src/components/Task.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import Task from './Task';

export const task = {
  id: '1',
  title: 'Test Task',
  state: 'TASK_INBOX',
  updatedAt: new Date(2018, 0, 1, 9, 0),
};

export const actions = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTask'),
};

storiesOf('Task', module)
  .add('default', () => <Task task={task} {...actions} />)
  .add('pinned', () => <Task task={{ ...task, state: 'TASK_PINNED' }} {...actions} />)
  .add('archived', () => <Task task={{ ...task, state: 'TASK_ARCHIVED' }} {...actions} />);
```

`.add`で追加したタスクが表示されていることを確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/a93c6cbc-6f9e-8606-0c26-f50b374bab1b.png)



状態、イベントを設定

```React:src/components/Task.js
import React from 'react';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  return (
    <div className={`list-item ${state}`}>
      <label className="checkbox">
        <input
          type="checkbox"
          defaultChecked={state === 'TASK_ARCHIVED'}
          disabled={true}
          name="checked"
        />
        <span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
      </label>
      <div className="title">
        <input type="text" value={title} readOnly={true} placeholder="Input title" />
      </div>

      <div className="actions" onClick={event => event.stopPropagation()}>
        {state !== 'TASK_ARCHIVED' && (
          <a onClick={() => onPinTask(id)}>
            <span className={`icon-star`} />
          </a>
        )}
      </div>
    </div>
  );
}
``` 


確認してみます。

![d.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/7452bb82-99f1-1b96-56af-840cace82d8e.gif)


その後、`propTypes`を設定することで問題を早期発見できるとのことでした。

```React:src/components/Task.js
import React from 'react';
import PropTypes from 'prop-types';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  // ...
}

Task.propTypes = {
  task: PropTypes.shape({
    id: PropTypes.string.isRequired,
    title: PropTypes.string.isRequired,
    state: PropTypes.string.isRequired,
  }),
  onArchiveTask: PropTypes.func,
  onPinTask: PropTypes.func,
};
```


### 自動テスト

アドオンとbabel-macrosを追加

```bash
yarn add --dev @storybook/addon-storyshots react-test-renderer require-context.macro
yarn add --dev babel-plugin-macros
```

```src/storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();
```

```.babelrc/package.json
{
  "plugins": ["macros"]
}
```

各ファイルの追加とconfig.jsの一部変更
`require.context`から`requireContext`に変更されています。

```react:.storybook/config.js
import { configure } from '@storybook/react';
import requireContext from 'require-context.macro';

import '../src/index.css';

const req = requireContext('../src/components', true, /\.stories\.js$/);

function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);
```

```bash:結果
$ yarn test
yarn run v1.17.3
$ react-scripts test
PASS src/storybook.test.js
  Storyshots
    Task
      √ default (15ms)
      √ pinned (2ms)
      √ archived (1ms)

 › 3 snapshots written.
Snapshot Summary
 › 3 snapshots written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   3 written, 3 total
Time:        9.264s
Ran all test suites related to changed files.
```

## Composite component

コンポーネントを組み合わせて実際の画面を確認してみます。


`TaskList.js`と`TaskList.stories.js`を作成
`addDecorator()`で各タスクにスタイルなどを追加できるようです。

```react:src/components/TaskList.js
import React from 'react';

import Task from './Task';

function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  if (loading) {
    return <div className="list-items">loading</div>;
  }

  if (tasks.length === 0) {
    return <div className="list-items">empty</div>;
  }

  return (
    <div className="list-items">
      {tasks.map(task => <Task key={task.id} task={task} {...events} />)}
    </div>
  );
}

export default TaskList;
```

```react:src/components/TaskList.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';

import TaskList from './TaskList';
import { task, actions } from './Task.stories';

export const defaultTasks = [
  { ...task, id: '1', title: 'Task 1' },
  { ...task, id: '2', title: 'Task 2' },
  { ...task, id: '3', title: 'Task 3' },
  { ...task, id: '4', title: 'Task 4' },
  { ...task, id: '5', title: 'Task 5' },
  { ...task, id: '6', title: 'Task 6' },
];

export const withPinnedTasks = [
  ...defaultTasks.slice(0, 5),
  { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];

storiesOf('TaskList', module)
  .addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
  .add('default', () => <TaskList tasks={defaultTasks} {...actions} />)
  .add('withPinnedTasks', () => <TaskList tasks={withPinnedTasks} {...actions} />)
  .add('loading', () => <TaskList loading tasks={[]} {...actions} />)
  .add('empty', () => <TaskList tasks={[]} {...actions} />);
```

![a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/c30eb92d-c009-6ba7-c18b-3af810e33e88.gif)

各状態のUIを設定

```src/components/TaskList.js
import React from 'react';

import Task from './Task';

function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  const LoadingRow = (
    <div className="loading-item">
      <span className="glow-checkbox" />
      <span className="glow-text">
        <span>Loading</span> <span>cool</span> <span>state</span>
      </span>
    </div>
  );

  if (loading) {
    return (
      <div className="list-items">
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }

  if (tasks.length === 0) {
    return (
      <div className="list-items">
        <div className="wrapper-message">
          <span className="icon-check" />
          <div className="title-message">You have no tasks</div>
          <div className="subtitle-message">Sit back and relax</div>
        </div>
      </div>
    );
  }

  const tasksInOrder = [
    ...tasks.filter(t => t.state === 'TASK_PINNED'),
    ...tasks.filter(t => t.state !== 'TASK_PINNED'),
  ];

  return (
    <div className="list-items">
      {tasksInOrder.map(task => <Task key={task.id} task={task} {...events} />)}
    </div>
  );
}

export default TaskList;
```

全体的にチュートリアル通りの表示になっていることを確認
![c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/e3bd73d4-66ce-5971-926c-2f1e2a9ee118.gif)

propTypesを設定

```react:TaskList.js
import React from 'react';
import PropTypes from 'prop-types';

import Task from './Task';

function TaskList() {
  ...
}

TaskList.propTypes = {
  loading: PropTypes.bool,
  tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  onPinTask: PropTypes.func.isRequired,
  onArchiveTask: PropTypes.func.isRequired,
};

TaskList.defaultProps = {
  loading: false,
};

export default TaskList;
```



## Data
アプリケーション側にデータの配線を行います。


`react-redux` `redux`をインストール

```bash
yarn add react-redux redux
```

単純なStoreを作成

```react:src/lib/redux.js
import { createStore } from 'redux';

export const actions = {
  ARCHIVE_TASK: 'ARCHIVE_TASK',
  PIN_TASK: 'PIN_TASK',
};

export const archiveTask = id => ({ type: actions.ARCHIVE_TASK, id });
export const pinTask = id => ({ type: actions.PIN_TASK, id });

function taskStateReducer(taskState) {
  return (state, action) => {
    return {
      ...state,
      tasks: state.tasks.map(
        task => (task.id === action.id ? { ...task, state: taskState } : task)
      ),
    };
  };
};

export const reducer = (state, action) => {
  switch (action.type) {
    case actions.ARCHIVE_TASK:
      return taskStateReducer('TASK_ARCHIVED')(state, action);
    case actions.PIN_TASK:
      return taskStateReducer('TASK_PINNED')(state, action);
    default:
      return state;
  }
};

const defaultTasks = [
  { id: '1', title: 'Something', state: 'TASK_INBOX' },
  { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  { id: '4', title: 'Something again', state: 'TASK_INBOX' },
];

export default createStore(reducer, { tasks: defaultTasks });
```

storeに接続し、該当タスクをレンダリングするよう`TaskList.js`を修正

```React:src/components/TaskList.js
import React from 'react';
import PropTypes from 'prop-types';

import Task from './Task';
import { connect } from 'react-redux';
import { archiveTask, pinTask } from '../lib/redux';

export function PureTaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  /* 以前のTaskList内を張り付ける */
}

PureTaskList.propTypes = {
  loading: PropTypes.bool,
  tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  onPinTask: PropTypes.func.isRequired,
  onArchiveTask: PropTypes.func.isRequired,
};

PureTaskList.defaultProps = {
  loading: false,
};

export default connect(
  ({ tasks }) => ({
    tasks: tasks.filter(t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'),
  }),
  dispatch => ({
    onArchiveTask: id => dispatch(archiveTask(id)),
    onPinTask: id => dispatch(pinTask(id)),
  })
)(PureTaskList);
```

`TaskList`が`PureTaskList`になっているので合わせて修正

```react:src/components/TaskList.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';

import { PureTaskList } from './TaskList';
import { task, actions } from './Task.stories';

export const defaultTasks = [
  { ...task, id: '1', title: 'Task 1' },
  { ...task, id: '2', title: 'Task 2' },
  { ...task, id: '3', title: 'Task 3' },
  { ...task, id: '4', title: 'Task 4' },
  { ...task, id: '5', title: 'Task 5' },
  { ...task, id: '6', title: 'Task 6' },
];

export const withPinnedTasks = [
  ...defaultTasks.slice(0, 5),
  { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];

storiesOf('TaskList', module)
  .addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
  .add('default', () => <PureTaskList tasks={defaultTasks} {...actions} />)
  .add('withPinnedTasks', () => <PureTaskList tasks={withPinnedTasks} {...actions} />)
  .add('loading', () => <PureTaskList loading tasks={[]} {...actions} />)
  .add('empty', () => <PureTaskList tasks={[]} {...actions} />);
```

## Screens

コンポーネントを組み合わせて
実際の画面を使い開発をしてみます。

`InboxScreen.js`を作成

```react:src/components/InboxScreen.js
import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";

import TaskList from "./TaskList";

export function PureInboxScreen({ error }) {
  if (error) {
    return (
      <div className="page lists-show">
        <div className="wrapper-message">
          <span className="icon-face-sad" />
          <div className="title-message">Oh no!</div>
          <div className="subtitle-message">Something went wrong</div>
        </div>
      </div>
    );
  }

  return (
    <div className="page lists-show">
      <nav>
        <h1 className="title-page">
          <span className="title-wrapper">Taskbox</span>
        </h1>
      </nav>
      <TaskList />
    </div>
  );
}

PureInboxScreen.propTypes = {
  error: PropTypes.string
};

PureInboxScreen.defaultProps = {
  error: null
};

export default connect(({ error }) => ({ error }))(PureInboxScreen);
```

`InboxScreen`のコンポーネントを使用するように`App.js`を修正

```react:src/App.js
import React, { Component } from "react";
import { Provider } from "react-redux";
import store from "./lib/redux";

import InboxScreen from "./components/InboxScreen";

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <InboxScreen />
      </Provider>
    );
  }
}

export default App;
```

チュートリアルにはありませんが、実際の画面を見てみます。

```bash
npm start
```

![f.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/a25ff478-44b2-83bd-3c2f-999d3b3ab98b.gif)

Storybookでも`InboxScreen`をレンダリングしてみます。

`InboxScreen.stories.js`を作成

```react:src/components/InboxScreen.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";

import { PureInboxScreen } from "./InboxScreen";

storiesOf("InboxScreen", module)
  .add("default", () => <PureInboxScreen />)
  .add("error", () => <PureInboxScreen error="Something" />);
```

チュートリアル通りですが、エラーが出ます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/d43a74e6-1d50-5a1e-8dca-0daacc8a3a08.png)
Presentational Component（見た目）をレンダリングするコンポーネントなので
Container Component（ロジック）を単独でレンダリングすることはできないようです。

今回の例では、`PureInboxScreen`単体はPresentational Componentですが
`TaskList`がそうではないことが原因のようです。


`.addDecorator`を使用し、取り回せるよう
`src/components/InboxScreen.stories.js`を修正

```react:InboxScreen.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { Provider } from "react-redux";

import { PureInboxScreen } from "./InboxScreen";
import { defaultTasks } from "./TaskList.stories";

const store = {
  getState: () => {
    return {
      tasks: defaultTasks
    };
  },
  subscribe: () => 0,
  dispatch: action("dispatch")
};

storiesOf("InboxScreen", module)
  .addDecorator(story => <Provider store={store}>{story()}</Provider>)
  .add("default", () => <PureInboxScreen />)
  .add("error", () => <PureInboxScreen error="Something" />);
```

正しく表示されていることを確認
![g.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/33efc828-5442-1a45-298e-8fa13a13f649.gif)

## Addons

アドオンを設定することができるようです

### Knobsをセットアップ

```bash
yarn add @storybook/addon-knobs
```

`.storybook/addons.js`に`addon-knobs`を追記

```react:.storybook/addons.js
import "@storybook/addon-actions/register";
import "@storybook/addon-knobs/register";
import "@storybook/addon-links/register";

```

`addon-knobs`のインポートの追加
`.addDecorator`にパラメーターを渡します
`default`のストーリーに`object`を結合

```react:src/components/Task.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withKnobs, object } from "@storybook/addon-knobs/react";

import Task from './Task';

export const task = {
  id: '1',
  title: 'Test Task',
  state: 'TASK_INBOX',
  updatedAt: new Date(2018, 0, 1, 9, 0),
};

export const actions = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTask'),
};

storiesOf('Task', module)
  .addDecorator(withKnobs)
  .add("default", () => {
    return <Task task={object("task", { ...task })} {...actions} />;
  })
  .add("pinned", () => (
    <Task task={{ ...task, state: "TASK_PINNED" }} {...actions} />
  ))
  .add("archived", () => (
    <Task task={{ ...task, state: "TASK_ARCHIVED" }} {...actions} />
  ));
```

`Knobs`のタブが追加されていて、defaultにパラメータ―が表示されていることを確認
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/20cf40b4-32f1-d49f-9859-788591ae7aba.png)

## その他（チュートリアル外）

### テーマを設定

https://storybook.js.org/docs/configurations/theming/

storybookのテーマが設定できるみたいなので設定してみました

```config.js
import { addParameters } from '@storybook/react';
import { themes } from '@storybook/theming';

// Option defaults.
addParameters({
  options: {
    theme: themes.dark,
  },
});
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/448157/974a75a2-8c6f-8542-09ee-15cb4eac69e8.png)

# 終わりに

「Creating addons」や「Deploy」などもありましたが
Storybookを実務で利用する方法とは若干逸れていると思い一部割愛させていただきました。

今回、チュートリアルを触ってみた個人的な感想を簡単にまとめると

良いところ
・コンポーネントカタログから直感的に必要なコンポーネントを探せる
・UIコンポーネントの再利用性が高い
・アプリを実行する必要が無く、Storybook単体でデザインの確認や実装ができる
・HMRが標準で設定されているため素早く動作確認ができる

気になるところ
・小規模プロジェクトだとわざわざ導入する必要は無いかも

個人的にはこんなところでした。
少し使い方は複雑かなとも思いましたが
慣れてくるとスムーズに開発できそうです。

弊社のプロジェクトでも導入されているため
実務部分についてはこれから理解を深めていきます。
応用に関しては知見が深まったらまたブログを書きます。

間違いやご指摘がありましたが是非ご教授頂ければと思います。