コンテンツにスキップ
Webフロントエンドエンジニアのための React Native 実践入門
Esc
navigateopen⌘Jpreview
このページの内容

第17章 テーマ・Storybook・テスト

ライト/ダークの切り替え、Storybookでのカタログ化、Jestのテスト、アイコンとスプラッシュの差し替えで、人に渡せる状態へ近づけます。

機能としては前章で動くようになりました。ここからは「人に渡せる状態」に近づける段階です。ライト/ダークの切り替え、Storybookでのコンポーネントカタログ、Jestのテスト、最後にアイコンとスプラッシュの差し替え方を扱います。

テーマシステムを組む

iOSもAndroidも、システム設定で外観モード(ライト/ダーク)を切り替えられます。アプリ側でもこれに追従するのが今や標準的な振る舞いです。サンプルアプリでは「システム / ライト / ダーク」の3つから選べるようにしました。

パレットを定義する

色の値はひとまず1ファイルにまとめます。

// src/theme/colors.ts
export type ColorScheme = "light" | "dark";

export type ThemeColors = {
  background: string;
  surface: string;
  border: string;
  divider: string;
  text: string;
  textSecondary: string;
  textMuted: string;
  primary: string;
  primaryMuted: string;
  onPrimary: string;
  destructive: string;
  destructiveMuted: string;
  shadow: string;
  overlay: string;
  surfaceElevated: string;
};

const light: ThemeColors = {
  background: "#f5f5f7",
  surface: "#ffffff",
  border: "#e0e0e0",
  divider: "#ececec",
  text: "#202124",
  textSecondary: "#5f6368",
  textMuted: "#9aa0a6",
  primary: "#208aef",
  primaryMuted: "#cfe6fb",
  onPrimary: "#ffffff",
  destructive: "#d93025",
  destructiveMuted: "#fbe1de",
  shadow: "#000000",
  overlay: "rgba(0, 0, 0, 0.4)",
  surfaceElevated: "#ffffff",
};

const dark: ThemeColors = {
  background: "#0d0d10",
  surface: "#1a1a1d",
  border: "#2a2a30",
  divider: "#26262b",
  text: "#f1f3f4",
  textSecondary: "#b0b3b8",
  textMuted: "#7a7d82",
  primary: "#4ea3f5",
  primaryMuted: "#1a3a5c",
  onPrimary: "#ffffff",
  destructive: "#f28b82",
  destructiveMuted: "#3a1f1d",
  shadow: "#000000",
  overlay: "rgba(0, 0, 0, 0.6)",
  surfaceElevated: "#222227",
};

export const palettes: Record<ColorScheme, ThemeColors> = { light, dark };

役割で名前を付けるのがコツです。background(画面背景)、surface(カード背景)、text(本文の色)のように、意味で取り出せるようにすると、ダーク用に#fff#1a1a1dに置き換えるだけで済みます。色そのもの(gray100blue500など)で名付けると、ライト/ダークの切り替えで地獄を見ます。

primaryはアクセントカラー、onPrimaryはその上に乗せるテキスト色、というiOS/Androidのデザインガイドでよく出てくる対応関係も意識しています。

テーマ解決を関数にする

「ユーザーの好み」と「システムのモード」から、最終的に使うColorSchemeを導く部分は純粋関数として切り出します。テストしやすくなり、見通しも良くなります。

// src/hooks/use-theme.ts
import { useColorScheme } from "react-native";

import { usePreferencesStore } from "@/stores/preferences";
import type { ThemePreference } from "@/stores/preferences";
import { palettes, type ColorScheme, type ThemeColors } from "@/theme/colors";

export type Theme = {
  scheme: ColorScheme;
  colors: ThemeColors;
};

type SystemColorScheme = "light" | "dark" | null | undefined;

export function resolveColorScheme(
  preference: ThemePreference,
  systemScheme: SystemColorScheme,
): ColorScheme {
  if (preference === "light" || preference === "dark") return preference;
  return systemScheme === "dark" ? "dark" : "light";
}

export function useTheme(): Theme {
  const systemScheme = useColorScheme();
  const preference = usePreferencesStore((state) => state.themePreference);

  const normalized: SystemColorScheme =
    systemScheme === "light" || systemScheme === "dark" ? systemScheme : null;
  const scheme = resolveColorScheme(preference, normalized);
  return { scheme, colors: palettes[scheme] };
}

useColorSchemeはReact Nativeが提供するフックで、システムの外観モードを返します。設定が変わると自動で再レンダーが走るので、Effectなどで監視する必要はありません。

resolveColorSchemeを独立した関数にしているのは、テストで全パターンを潰せるようにするためです。

// src/hooks/use-theme.test.ts
describe("resolveColorScheme", () => {
  it("preference が 'light' のときは system を無視して light を返す", () => {
    expect(resolveColorScheme("light", "dark")).toBe("light");
  });

  it("preference が 'system' で system が null のときは light にフォールバック", () => {
    expect(resolveColorScheme("system", null)).toBe("light");
  });
});

色の選び方は感覚に頼る部分が多いですが、ロジック部分はテストで守れます。

コンポーネント側で使う

各コンポーネントでは、useThemeを呼んで色を取り出し、StyleSheet.createに渡す関数を作る形にしてあります。

const styles = createStyles(colors);

const createStyles = (colors: ThemeColors) =>
  StyleSheet.create({
    container: { backgroundColor: colors.background },
    title: { color: colors.text },
  });

StyleSheet.createはコンポーネント外で1回だけ呼んだほうが速いのでは?」と思った人は鋭い感覚です。ただ、ライト/ダーク切り替えのたびに新しいstylesオブジェクトが必要になるので、関数の中で都度作っています。再レンダーごとに作り直すコストは小さく、React Compilerが入っている環境ではほぼ気にせずに済みます。

もう一段ラクにしたい場合

createStyles(colors)を毎ファイルで書くのが煩わしく感じてきたら、テーマの受け渡しごとライブラリに任せる手があります。第7章で挙げたスタイリングの選択肢が、そのままここでの選択肢になります。

StyleSheetの書き味を保ちたいならreact-native-unistyles です。テーマやブレークポイントをライブラリ側が管理してくれるので、コンポーネント側はテーマを「引数」として受け取る形で書けます。

import { StyleSheet } from "react-native-unistyles";

const styles = StyleSheet.create((theme) => ({
  container: { backgroundColor: theme.colors.background },
  title: { color: theme.colors.text },
}));

テーマを切り替えると内部で再計算されるので、自前でuseThemeからcolorsを引き回す必要がなくなります。

Tailwindライクな書き味が好みなら、第7章で挙げたUniwindやNativeWind が同じ問題を別の形で解きます。色をテーマ設定側に集約しておき、コンポーネントではdark:バリアントで明暗を書き分ける、という進め方です。

<View tw="bg-white dark:bg-neutral-900 p-4">
  <Text tw="text-neutral-900 dark:text-neutral-100">タイトル</Text>
</View>

こちらもcolorsを props やフック経由で引き回す必要はありません。

どれを選ぶかは、テーマ機能の優劣というより第7章で決めたスタイリングの方針をそのまま延長する話だと考えてください。StyleSheetで通すならUnistyles、Tailwindで通すならUniwindかNativeWind、という対応です。テーマのためだけに別系統のライブラリを足すと、スタイルの書き方が二重になって取り回しが悪くなります。

本書のサンプルでは「依存を増やさず最小構成で組む」方針を優先しているのでいずれも入れていませんが、コンポーネント数が増えてきた段階で導入するのは十分に有力な選択肢です。

ヘッダーやタブバーの色は、React Navigationが内部で管理しています。自前のパレットだけ切り替えてもヘッダーが白いままだと違和感が出るので、Root LayoutでThemeProviderに自分のパレットを混ぜ込んでいる、というのが第15章の冒頭で出てきた話です。

ここまで揃うと、設定画面で「ダーク」を選んだ瞬間、画面全体・タブ・モーダルが一斉に切り替わります。ユーザーの選択肢がpersistによりAsyncStorageに保存されているので、アプリを再起動しても状態は保たれます。

カラーパレットとユーザーのテーマ選択がuseThemeを経由してコンポーネントとNavigationテーマに届く流れ

ライト/ダーク切り替えの一覧画面比較

同じTODO一覧画面をライト/ダークそれぞれで表示したものです。設定画面でテーマを切り替えると、画面全体・タブ・モーダルが一斉に切り替わります。

Storybookでコンポーネントを並べる

コンポーネントが増えてくると「どんな部品があるか」「propsを変えると見た目がどう変わるか」を一覧したくなります。Webと同じく、React NativeでもStorybookが使えます。

ネイティブで動かすか、ブラウザで動かすか

React NativeのStorybookには実行環境が2つあり、最初にどちらを使うか決めることになります。

  • @storybook/react-native(オンデバイス): アプリの中の1画面としてStorybookを表示し、シミュレータや実機で動かす
  • @storybook/react-native-web-vite(ブラウザ): React Native for Web経由で、Webと同じようにブラウザ上で動かす

Storybook公式も両者の違いを整理しています。オンデバイス版の強みは忠実さで、ネイティブモジュールを使うコンポーネントも実機やシミュレータでそのまま確認できます。ただし使える機能はWeb版より限られます。

Web版の強みは周辺エコシステムです。Storybookを公開してチームや外部と共有できること、MDXによるドキュメント、コンポーネントテスト・ビジュアルテスト・アクセシビリティテスト、そして500を超えるアドオンが挙げられています。弱点はReact Native for Webを経由する点で、公式も「大半のコンポーネントでは動くが制約はある」と明記しています。

本書はオンデバイス版で進めます。第16章で作ったTodoItemのように、押下時の見た目やスワイプ操作といった手触りを確かめたい部品が中心なので、実機での見え方を優先したいからです。共有やビジュアルテストまで踏み込みたくなったらWeb版が有力になります。この使い分けは、第20章でAIエージェントとの相性という別の角度からもう一度取り上げます。

セットアップ

オンデバイス版の@storybook/react-nativeを入れます。アプリ内のひとつの画面としてStorybookを表示する作りなので、シミュレータや実機で実際の見た目をそのまま確認できます。

npx expo install @storybook/react-native @storybook/addon-ondevice-controls @storybook/addon-ondevice-actions

metro.config.jsにプラグインを噛ませます。

// metro.config.js
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config");
const { withStorybook } = require("@storybook/react-native/metro/withStorybook");

const config = getDefaultConfig(__dirname);

module.exports = withStorybook(config, {
  enabled: true,
  configPath: path.resolve(__dirname, "./.rnstorybook"),
});

.rnstorybook/main.tsでストーリーの場所を指定します。

// .rnstorybook/main.ts
import type { StorybookConfig } from "@storybook/react-native";

const main: StorybookConfig = {
  stories: ["../src/components/**/*.stories.?(ts|tsx|js|jsx)"],
  addons: [
    "@storybook/addon-ondevice-controls",
    "@storybook/addon-ondevice-actions",
  ],
};

export default main;

そして、Storybookを表示する画面を1つExpo Routerに追加します。

// src/app/storybook.tsx
export { default } from "../../.rnstorybook";

これだけで/storybookに遷移するとStorybookが開きます。設定画面の「Storybook を開く」ボタンから飛べるように、第15章で__DEV__で囲った導線を入れていました。

ストーリーを書く

各コンポーネントに*.stories.tsxを1ファイル添えます。TodoItemの例です。

// src/components/todo-item/todo-item.stories.tsx
import type { Meta, StoryObj } from "@storybook/react-native";
import { View } from "react-native";

import { TodoItem } from "./todo-item";

const meta = {
  title: "Components/TodoItem",
  component: TodoItem,
  decorators: [(Story) => <View style={{ paddingVertical: 8 }}><Story /></View>],
  args: {
    onPress: () => {},
    onToggle: () => {},
  },
  argTypes: {
    onPress: { action: "pressed" },
    onToggle: { action: "toggled" },
  },
} satisfies Meta<typeof TodoItem>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Active: Story = {
  args: {
    todo: { id: "1", title: "牛乳を買う", completed: false, createdAt: Date.now() },
  },
};

export const Completed: Story = {
  args: {
    todo: { id: "2", title: "燃えるゴミを出す", completed: true, createdAt: Date.now() },
  },
};

export const LongTitle: Story = {
  args: {
    todo: {
      id: "3",
      title: "TODO アプリのリファクタリングをしてダークモードに対応してテストも書く",
      completed: false,
      createdAt: Date.now(),
    },
  },
};

通常時、完了時、長文タイトルの3パターンを並べておくと、CSS的な変更を加えたときに「どれが壊れたか」を画面で確認できます。argTypesactionを渡すと、Storybook内でボタンを押したときにイベントログが流れます。

新しい*.stories.tsxを追加したら、ストーリー一覧を再生成します。

npm run storybook-generate

これは.rnstorybook/storybook.requires.tsを自動更新するコマンドで、Storybook for React Nativeはこのファイルを読んで各ストーリーを動的に取り込みます。

Storybookで TodoItem/Active ストーリーを表示した画面

下部のツールバーに現在のストーリーパス(Components/TodoItem/Active)が表示されています。左下のアイコンからストーリー一覧を開くと、CompletedLong Titleなど他のバリエーションにも切り替えられます。

本番ビルドでは無効化する

metro.config.jsenabled: trueを本番でも有効にしたままだと、Storybookのコードがバンドルに含まれてしまいます。リリース時には環境変数で制御する書き換えを行います。

module.exports = withStorybook(config, {
  enabled: process.env.STORYBOOK_ENABLED === "true",
  configPath: path.resolve(__dirname, "./.rnstorybook"),
});

開発時はSTORYBOOK_ENABLED=true npx expo start、本番ビルドでは未設定で除外、という運用になります。第18章で扱うEAS Buildのプロファイル設定とあわせて使うとシンプルです。

Jestでテストを書く

Jestの最小セットアップ(package.jsonの設定とjest.setup.tsでのAsyncStorageモック)は、テストが最初に登場した第14章で済ませてあります。ここでは何をテストするか、Storybookとの役割分担をどう考えるかを整理します。

Storybookとの役割分担

StorybookのStoryとJestのテストは競合しません。Storyは「見た目を目で確認する」ためのもので、argsに渡したtodoオブジェクトのような値は、そのままJestのテストのフィクスチャとしても使い回せます。役割を分けると次のようになります。

  • Storybook: 見た目・レイアウト崩れをレビューする。スナップショットの代わりに人の目で確認する場
  • Jest: 「押したら何が呼ばれるか」「状態がどう変わるか」という振る舞いを固定する場

TodoItem.stories.tsxActive/Completed/LongTitleのような代表パターンは、そのままJestのdescribeブロックのケース分けの元ネタにもなります。

何をテストするか

実装してみて分かるのは、テストするべき場所が意外と少ないことです。本書のサンプルでは次の3カテゴリだけテストを書いています。

  • ストアのロジック: addTodoが末尾に追加するか、toggleTodoが反転するか、など
  • 純粋関数: resolveColorSchemeの全パターン
  • インタラクションが分かりにくい部品: FilterBarのチップ押下でonChangeが正しく呼ばれるか

UI全体のスナップショットテストは、本書では推奨しません。ちょっとしたスタイル調整で大量に差分が出ますし、実害のないdiffを承認する作業が増えるだけです。振る舞いに対するテストを少なく書くほうが、長く保ちます。

npm test

実行するとPASSがいくつか並んで終わるはずです。CIで回す場合はnpm test -- --ci --watchAll=falseの組み合わせがよく使われます。

アイコンとスプラッシュ

「アプリらしさ」を出す最後の仕上げが、アプリアイコンとスプラッシュ(起動時の画面)です。Expoではapp.jsonの設定とPNG画像の差し替えだけで対応できます。

必要な画像

最低限揃えるのは次の3〜4枚です。

  • アイコン(iOS): 1024×1024のPNG。透過なし、角丸なしでOK(OS側が処理する)
  • アイコン(Android Adaptive): 前景・背景を別レイヤーで作る。1024×1024推奨
  • スプラッシュ画像: 中央に置きたいロゴ画像。splash-icon.png相当

サンプルアプリではassets/images/配下にひとまずテンプレ画像が置かれています。

app.jsonで指定する

{
  "expo": {
    "name": "sample-todo-app",
    "icon": "./assets/images/icon.png",
    "ios": {
      "icon": "./assets/expo.icon"
    },
    "android": {
      "adaptiveIcon": {
        "backgroundColor": "#E6F4FE",
        "foregroundImage": "./assets/images/android-icon-foreground.png",
        "backgroundImage": "./assets/images/android-icon-background.png",
        "monochromeImage": "./assets/images/android-icon-monochrome.png"
      }
    },
    "plugins": [
      "expo-router",
      [
        "expo-splash-screen",
        {
          "backgroundColor": "#208AEF",
          "android": {
            "image": "./assets/images/splash-icon.png",
            "imageWidth": 76
          }
        }
      ]
    ]
  }
}

iOSのアイコンは./assets/expo.iconのように.iconディレクトリを参照する形が、SDK 54以降の推奨です。XcodeのAppIcon.appiconsetに相当する構造で、ライト/ダーク/ティント別の3バリアントを内包できます。

スプラッシュはexpo-splash-screenプラグインに設定を渡します。backgroundColorはアプリのブランドカラーに、imageWidthはロゴの最大幅です。

反映を確認する

アイコンとスプラッシュの差し替えは、Expo Goでは確認できません。EAS BuildやDevelopment Build(第4章を参照)でアプリを焼き直す必要があります。最初は「画像を差し替えたのに変わらない」と戸惑いやすいので、開発の早い段階で1度Development Buildを作っておくと心配が減ります。

app.jsonで指定したアイコンがiOSとAndroidのホーム画面に反映されるイメージ

起動、スプラッシュ表示、TODO一覧画面の順で遷移するイメージ

設定の細かい仕様(アイコンの推奨サイズ、ダークモード用のティント、Android 13以降のmonochromeアイコンなど)はExpoの公式ドキュメントに最新の情報があります。expo-splash-screenadaptiveIconの項を一度ざっと読んでおくと、後で困りません。


仕上げが整ったので、いよいよ次章で外の世界に出します。EAS Buildを使ってクラウドでビルドし、TestFlight/内部テストに配り、最後にApp Store/Google Playへの提出までを駆け抜けます。

最終更新 2026年7月24日