0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

StoryBookを基本からまとめてみた【入門】

Last updated at Posted at 2025-11-27

StoryBookとは?

  • 主にUIコンポーネントを独立した環境で開発、テスト、文書化するために使用される、オープンソースのツール

StoryBookを起動する

$ npm run storybook 

StoryBookの使い方

タスクUIコンポーネントを作成する

①components/Task.jsxを作成する

components/Task.jsx
export default function Task({ task: { id, title, state } }) {
  return (
    <div className='list-item'>
      <label htmlFor='title' className='title'>
        <input
          type='text'
          value={title}
          readOnly={true}
          name='title'
          placeholder='Input title'
        />
      </label>
    </div>
  );
}

タスクUI用のStoryBookを準備する

①components/Task.stories.jsxを作成する

components/Task.stories.jsx
import Task from './Task';

export default {
  Components: Task,
  title: 'Task',
};

export const Default = {
  args: {
    task: {
      id: '1',
      title: 'test task',
      state: 'TASK_INBOX',
    },
  },
};

②preview.tsにimportを追加する

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

タスクUIコンポーネントを修正する

components/Task.jsx
export default function Task({ task: { id, title, state } }) {
  return (
    <div className={`list-item ${state}`}>
      <label htmlFor='checked' className='checkbox'>
        <input type='checkbox' name='checked' id={`archiveTask-${id}`} />
        <span className='checkbox-custom'></span>
      </label>
      <label htmlFor='title' className='title'>
        <input
          type='text'
          value={title}
          readOnly={true}
          name='title'
          placeholder='Input title'
        />
      </label>

      {state !== 'TASK_ARCHIVED' && (
        <button className='pin-button' id={`pinTask-${id}`}>
          <span className='icon-star'></span>
        </button>
      )}
    </div>
  );
}

タスク状態によってUIが変わるカタログを追加

components/Task.stories.jsx
import Task from './Task';

export default {
  Components: Task,
  title: 'Task',
};

export const Default = {
  args: {
    task: {
      id: '1',
      title: 'test task',
      state: 'TASK_INBOX',
    },
  },
};

export const Pinned = {
  args: {
    task: {
      //   id: '1',
      //   title: 'test task',
      //   state: 'TASK_PINNED',

      //スプレッド構文
      ...Default.args.task,
      state: 'TASK_PINNED',
    },
  },
};

export const Archived = {
  args: {
    task: {
      //   id: '1',
      //   title: 'test task',
      //   state: 'TASK_PINNED',

      //スプレッド構文
      ...Default.args.task,
      state: 'TASK_ARCHIVED',
    },
  },
};

コンポーネントのpropsで渡す値に型を定義する

components/Task.jsx
import PropType from 'prop-types';

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

      {state !== 'TASK_ARCHIVED' && (
        <button className='pin-button' id={`pinTask-${id}`}>
          <span className='icon-star'></span>
        </button>
      )}
    </div>
  );
}

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

アドオンを追加してアクセシビリティを実行する

storybook/main.js
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  staticDirs: ['../public'],
  addons: [
    '@storybook/addon-docs',
    '@storybook/addon-vitest',
    '@chromatic-com/storybook',
    //追加
    '@storybook/addon-ally',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};
export default config;
components/Task.jsx
import PropType from 'prop-types';

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

      {state !== 'TASK_ARCHIVED' && (
        <button
          className='pin-button'
          id={`pinTask-${id}`}
          aria-label={`pinTask-${id}`}
        >
          <span className='icon-star'></span>
        </button>
      )}
    </div>
  );
}

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

複合コンポーネントのタスクリストを作成する

①components/TaskList.jsxを作成する

components/TaskList.jsx
export default function TaskList({ Loading, tasks }) {
  if (Loading) {
    return <div className='list-item'>Loading...</div>;
  }

  if (tasks.length === 0) {
    return <div className='list-item'>Empty...</div>;
  }
  return (
    <div className='list-item'>
      {tasks.map((task) => (
        <Task key={task.id} task={task} />
      ))}
    </div>
  );
}

タスクリストUI用のStoryBookを追加する

①components/TaskList.stories.jsxを作成する

components/TaskList.stories.jsx
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';

export default {
  Components: TaskList,
  title: 'TaskList',
  decorators: [
    (Story) => (
      <div style={{ padding: '3rem' }}>
        <Story />
      </div>
    ),
  ],
};

export const Default = {
  args: {
    tasks: [
      //   {
      //     id: '1',
      //     title: 'Task 1',
      //     state: 'TASK_INBOX',
      //   },
      //   {
      //     id: '2',
      //     title: 'Task 2',
      //     state: 'TASK_INBOX',
      //   },
      //   {
      //     id: '3',
      //     title: 'Task 3',
      //     state: 'TASK_INBOX',
      //   },
      //スプレッド構文
      {
        ...TaskStories.Default.args.task,
        id: '1',
        title: 'Task 1',
      },
      {
        ...TaskStories.Default.args.task,
        id: '2',
        title: 'Task 2',
      },
      {
        ...TaskStories.Default.args.task,
        id: '3',
        title: 'Task 3',
      },
      {
        ...TaskStories.Default.args.task,
        id: '4',
        title: 'Task 4',
      },
      {
        ...TaskStories.Default.args.task,
        id: '5',
        title: 'Task 5',
      },
      {
        ...TaskStories.Default.args.task,
        id: '6',
        title: 'Task 6',
      },
    ],
  },
};

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

ローディング・空状態のStoryBook・UIコンポーネントを作成する

components/TaskList.stories.jsx
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';

export default {
  Components: TaskList,
  title: 'TaskList',
  decorators: [
    (Story) => (
      <div style={{ padding: '3rem' }}>
        <Story />
      </div>
    ),
  ],
};

export const Default = {
  args: {
    tasks: [
      //   {
      //     id: '1',
      //     title: 'Task 1',
      //     state: 'TASK_INBOX',
      //   },
      //   {
      //     id: '2',
      //     title: 'Task 2',
      //     state: 'TASK_INBOX',
      //   },
      //   {
      //     id: '3',
      //     title: 'Task 3',
      //     state: 'TASK_INBOX',
      //   },
      //スプレッド構文
      {
        ...TaskStories.Default.args.task,
        id: '1',
        title: 'Task 1',
      },
      {
        ...TaskStories.Default.args.task,
        id: '2',
        title: 'Task 2',
      },
      {
        ...TaskStories.Default.args.task,
        id: '3',
        title: 'Task 3',
      },
      {
        ...TaskStories.Default.args.task,
        id: '4',
        title: 'Task 4',
      },
      {
        ...TaskStories.Default.args.task,
        id: '5',
        title: 'Task 5',
      },
      {
        ...TaskStories.Default.args.task,
        id: '6',
        title: 'Task 6',
      },
    ],
  },
};

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

export const Loading = {
  args: {
    tasks: [],
    loading: true,
  },
};

export const Empty = {
  args: {
    ...Loading.args,
    loading: false,
  },
};
components/TaskList.jsx
import Task from './Task';

export default function TaskList({ Loading, tasks }) {
  const loadingRow = (
    <div className='loading-item'>
      <span className='glow-checkbox'></span>
      <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' />
          <p className='title-message'>You have no tasks</p>
          <p className='subtitle-message'>Sit back and relax</p>
        </div>
      </div>
    );
  }
  return (
    <div className='list-item'>
      {tasks.map((task) => (
        <Task key={task.id} task={task} />
      ))}
    </div>
  );
}

タスクリストで使用するデータの型を定義する

components/TaskList.jsx
import Task from './Task';
import PropType from 'prop-types';

export default function TaskList({ Loading, tasks }) {
  const loadingRow = (
    <div className='loading-item'>
      <span className='glow-checkbox'></span>
      <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' />
          <p className='title-message'>You have no tasks</p>
          <p className='subtitle-message'>Sit back and relax</p>
        </div>
      </div>
    );
  }
  return (
    <div className='list-item'>
      {tasks.map((task) => (
        <Task key={task.id} task={task} />
      ))}
    </div>
  );
}

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

TaskList.defaultProps = {
  loading: false,
};

データをUIコンポーネントに接続する

Reduxとは?

  • 『アクション』と呼ばれるイベントを使用してアプリケーションの状態管理及び更新するためのパターン及びライブラリ
$ npm install @reduxjs/toolkit
$ npm install react-redux

①src/lib/store.jsを作成する

src/lib/store.js
const { createSlice } = require('@reduxjs/toolkit');

const defaultTasks = [
  { id: '1', title: 'something1', state: 'TASK_INBOX' },
  { id: '2', title: 'something2', state: 'TASK_INBOX' },
  { id: '3', title: 'something3', state: 'TASK_INBOX' },
  { id: '4', title: 'something4', state: 'TASK_INBOX' },
];

const TaskBoxData = {
  tasks: defaultTasks,
  status: 'idle',
  error: null,
};

const TaskSlice = createSlice({
  name: 'taskbox',
  initialState: TaskBoxData,
  reducers: {
    updateTaskState: (state, action) => {
      const { id, newTaskState } = action.payload;
      const tasks = state.tasks.findIndex((task) => task.id === id);
      if (tasks >= 0) {
        state.tasks[tasks].state = newTaskState;
      }
    },
  },
});

Storeを作ってアプリ全体でデータ共有ができる準備する

src/lib/store.js
const { createSlice, configureStore } = require('@reduxjs/toolkit');

const defaultTasks = [
  { id: '1', title: 'something 1', state: 'TASK_INBOX' },
  { id: '2', title: 'something 2', state: 'TASK_INBOX' },
  { id: '3', title: 'something 3', state: 'TASK_INBOX' },
  { id: '4', title: 'something 4', state: 'TASK_INBOX' },
];

const TaskBoxData = {
  tasks: defaultTasks,
  status: 'idle',
  error: null,
};

const TaskSlice = createSlice({
  name: 'taskbox',
  initialState: TaskBoxData,
  reducers: {
    updateTaskState: (state, action) => {
      const { id, newTaskState } = action.payload;
      const tasks = state.tasks.findIndex((task) => task.id === id);
      if (tasks >= 0) {
        state.tasks[tasks].state = newTaskState;
      }
    },
  },
});

export const { updateTaskState } = TaskSlice.actions;

const store = configureStore({
  reducer: {
    taskbox: TaskSlice.reducer,
  },
});

export default store;

タスクリストのソート処理を行う

TaskList.js
import { useSelector } from 'react-redux';
import Task from './Task';
import PropType from 'prop-types';

export default function TaskList({ Loading, tasks }) {
  const tasks = useSelector((state) => {
    const tasksInOrder = [
      ...state.taskbox.tasks.filter((task) => task.id === 'TASK_PINNED'),
      ...state.taskbox.tasks.filter((task) => task.id !== 'TASK_PINNED'),
    ];
    const filteredTasks = tasksInOrder.filter(
      (task) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
    );
    return filteredTasks;
  });

  const loadingRow = (
    <div className='loading-item'>
      <span className='glow-checkbox'></span>
      <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' />
          <p className='title-message'>You have no tasks</p>
          <p className='subtitle-message'>Sit back and relax</p>
        </div>
      </div>
    );
  }
  return (
    <div className='list-item'>
      {tasks.map((task) => (
        <Task key={task.id} task={task} />
      ))}
    </div>
  );
}

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

TaskList.defaultProps = {
  loading: false,
};

参考サイト

【初心者のためのStorybook入門】UIコンポーネント管理の基礎から学ぶStoryBook入門講座

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?