React Native for Web + TypeScriptを使ってStorybook公式のチュートリアルをやってみた

2020.06.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Storybookとは、React、Vue、Angular 用の UI コンポーネントを開発・管理するためのオープンソースツールです。 公式ページにあるチュートリアルを、普段の業務で使用しているReact Native for WebTypeScriptを使ってやってみたのでご紹介します。

実装するもの

今回作成したソースコードの一式はこちらにあります。

実装方針

  • Create React App を使ってプロジェクトを作成する
  • TypeScript を使用する
  • 画面の作成に、React Native for Webのコンポーネントを使用する
  • チュートリアルの内容(*2020/6/12 時点)に沿う *忠実に再現したものではなく、適時変更を加えている点をご了承ください。

プロジェクトの作成

Create React Appを使って React アプリを作成し、TypeScriptReact Native for Webを導入します。今回は以下の記事でご紹介した方法で React アプリを作成し、TypeScriptReact Native for WebPrettierを導入しました。

* 以下、React Native for Web は RNfW と記載します。

Get Started

Storybook を導入する

以下のコマンドを実行し、Storybook を導入します。

$ npx -p @storybook/cli sb init

このコマンドによって.storybookフォルダsrc/storiesフォルダが自動で作成されます。 処理が終了したら、yarn storybookを実行し、Storybook が立ち上がるのを確認しましょう。

TypeScript、RNfW を実行するための設定を追加する

src/storiesフォルダ内に作られた story ファイルの拡張子を見ると.jsとなっています。こちらを.tsxとしても実行できるようにします。

.storybook/main.jsstories: ['../src/**/*.stories.js']の箇所をstories: ['../src/**/*.stories.tsx']と変更しましょう。

module.exports = {
  stories: ['../src/**/*.stories.tsx'],
  ....
}   }

src/storiesフォルダ内の.stories.jsファイルを.stories.tsxと拡張子を変更し、yarn storybookで実行できることを確認します。

また、今回 RNfW を使用するため、.storybookフォルダ内に、webpack.config.jsを作成し、以下の通り記載しました。

module.exports = {
  resolve: {
    alias: {
      'react-native$': 'react-native-web'
    }
  }}

*設定内容についてこちらの記事を参照させていただきました。

ここまでで、チュートリアルを始める準備は完了です。

Build a simple component

まず、タスク1件の情報を表示する Task コンポーネントを作成します。 このコンポーネントは「チェックボックス」「タスクタイトル」「お気に入りチェック用の ☆ マーク」で構成されています。 それぞのタスクはDefault(TASK_INBOX)/ Pinned(TASK_PINNED)/ Archived(TASK_ARCHIVED)の3つの状態を持ち、異なる UI を表示します。

src/components/Task.tsx

import React from 'react'
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'

const styles = StyleSheet.create({
  container: {
    height: 50,
    borderWidth: 1,
    borderColor: 'black',
    justifyContent: 'center',
  },
  rowContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
  },
  checkAndTitle: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'flex-start',
  },
  checkbox: {
    marginLeft: 24,
    fontSize: 24,
    fontWeight: 'bold',
  },
  title: {
    marginLeft: 24,
    fontSize: 16,
    fontWeight: 'bold',
  },
  star: {
    marginRight: 24,
    fontSize: 18,
  },
})

export type TaskProps = {
  task: {
    id: string
    title: string
    state: 'TASK_INBOX' | 'TASK_ARCHIVED' | 'TASK_PINNED'
  }
  onArchiveTask: (id: string) => void
  onPinTask: (id: string) => void
}

function Task(props: TaskProps) {
  const {
    task: { id, title, state },
    onArchiveTask,
    onPinTask,
  } = props

  return (
    <View style={styles.container}>
      <View style={styles.rowContainer}>
        <View style={styles.checkAndTitle}>
          <TouchableOpacity onPress={() => onArchiveTask(id)}>
            <Text style={styles.checkbox}>
              {state === 'TASK_ARCHIVED' ? '☑︎' : '□'}
            </Text>
          </TouchableOpacity>
          <Text style={styles.title}>{title}</Text>
        </View>
        {state !== 'TASK_ARCHIVED' && (
          <TouchableOpacity onPress={() => onPinTask(id)}>
            <Text style={styles.star}>
              {state === 'TASK_PINNED' ? '★' : '☆'}
            </Text>
          </TouchableOpacity>
        )}
      </View>
    </View>
  )
}

export default Task

続いて、Default(TASK_INBOX)/ Pinned(TASK_PINNED)/ Archived(TASK_ARCHIVED)それぞれの状態の UI を Storybook で確認できるよう story ファイルを作成します。

src/components/Task.stories.tsx

import React from 'react'
import { action } from '@storybook/addon-actions'

import Task from './Task'

export default {
  component: Task,
  title: 'Task',
  excludeStories: /.*Data$/,
}

export const taskData: {
  id: string
  title: string
  state: 'TASK_INBOX' | 'TASK_ARCHIVED' | 'TASK_PINNED'
} = {
  id: '1',
  title: 'Test Task',
  state: 'TASK_INBOX',
}

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

export const Default = () => <Task task={{ ...taskData }} {...actionsData} />

export const Pinned = () => (
  <Task task={{ ...taskData, state: 'TASK_PINNED' }} {...actionsData} />
)

export const Archived = () => (
  <Task task={{ ...taskData, state: 'TASK_ARCHIVED' }} {...actionsData} />
)

yarn storybookを実行します。

「Default(チェック:なし、星:☆ を表示)」「Pinned(チェック:なし、星:★ を表示)」「Archived(チェック:あり、星:表示なし)」 と、それぞれの状態での UI が Storybook 上で確認できるようになりました。

Assemble a composite component

続いて、タスクを一覧表示するための TaskList コンポーネントを作成します。

src/components/TaskList.stories.tsx

import React from 'react'
import { View, Text } from 'react-native'

import Task from './Task'

export type TaskListProps = {
  tasks: {
    id: string
    title: string
    state: 'TASK_INBOX' | 'TASK_ARCHIVED' | 'TASK_PINNED'
  }[]
  onArchiveTask: (id: string) => void
  onPinTask: (id: string) => void
}

function TaskList(props: TaskListProps) {
  const { tasks, onArchiveTask, onPinTask } = props

  if (tasks.length === 0) {
    return (
      <View>
        <Text>You have no task.</Text>
      </View>
    )
  }

  return (
    <View>
      {tasks.map(task => (
        <Task
          key={task.id}
          task={task}
          onPinTask={onPinTask}
          onArchiveTask={onArchiveTask}
        />
      ))}
    </View>
  )
}

export default TaskList

今回は「タスク6件全てが Default 状態の TaskList」「最後の1件のタスクが Pinned 状態の TaskList」「最後の1件のタスクが Archived 状態の TaskList」「タスクがない場合の TaskList」の それぞれの UI が確認できるように story ファイルを作成しました。

src/TaskList.stories.tsx

import React from 'react'

import TaskList from './TaskList'
import { taskData, actionsData } from './Task.stories'

export default {
  component: TaskList,
  title: 'TaskList',
  decorators: [story => <div style={{ padding: '3rem' }}>{story()}</div>],
  excludeStories: /.*Data$/,
}

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

export const withPinnedTasksData: {
  id: string
  title: string
  state: 'TASK_INBOX' | 'TASK_ARCHIVED' | 'TASK_PINNED'
}[] = [
  ...defaultTasksData.slice(0, 5),
  { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
]

export const withArchivedTasksData: {
  id: string
  title: string
  state: 'TASK_INBOX' | 'TASK_ARCHIVED' | 'TASK_PINNED'
}[] = [
  ...defaultTasksData.slice(0, 5),
  { id: '6', title: 'Task 6 (archived)', state: 'TASK_ARCHIVED' },
]

export const Default = () => (
  <TaskList tasks={defaultTasksData} {...actionsData} />
)

export const WithPinnedTasks = () => (
  <TaskList tasks={withPinnedTasksData} {...actionsData} />
)

export const WithArchivedTasks = () => (
  <TaskList tasks={withArchivedTasksData} {...actionsData} />
)

export const Empty = () => <TaskList tasks={[]} {...actionsData} />

再度 yarn storybookで、Storybook を起動します。

TaskList コンポーネントのそれぞれの状態での UI が確認できるようになりました。

まとめ

Storybook 公式のチュートリアルを React Native for Web + TypeScript で実装した内容をご紹介しました。

今回自身の入門としてチュートリアルに取り組みましたが、コンポーネントの UI を一覧形式で管理でき、それぞれの状態による変化も非常に把握しやすくなる点など、Storybook を導入するメリットを感じることができました。Addon の機能などについても、今後さらに調べていければと思います。

この記事がどなたかのお役に立てば幸いです。