T-CREATOR

モバイルアプリ開発で使う Zustand:React Native 連携ノウハウ

モバイルアプリ開発で使う Zustand:React Native 連携ノウハウ

React Native でのモバイルアプリ開発において、状態管理は避けて通れない重要な要素です。特に複雑なアプリケーションになればなるほど、効率的で保守性の高い状態管理ライブラリの選択が成功の鍵を握ります。

今回は、軽量でありながら強力な状態管理ライブラリ「Zustand」を使って、React Native アプリケーションでの実践的な開発ノウハウをご紹介します。実際のプロジェクトですぐに活用できる具体的な実装例とともに、パフォーマンス最適化やデバッグ手法まで幅広くカバーしていきますね。

Zustand とは

Zustand は、ドイツ語で「状態」を意味する軽量な状態管理ライブラリです。Redux のような複雑な設定を必要とせず、シンプルな API でありながら十分な機能を提供してくれます。

Zustand の主な特徴

特徴説明
軽量性バンドルサイズが非常に小さく、モバイルアプリに最適
シンプル API学習コストが低く、直感的に使用可能
TypeScript 対応型安全性を保ちながら開発可能
DevTools 対応開発時のデバッグが容易
非同期処理Promise ベースの非同期処理を自然に扱える

Zustand が特に優れているのは、ボイラープレートコードの削減です。Redux と比較すると、アクションやリデューサーの定義が不要で、直接的な状態更新が可能になります。

typescript// Zustandの基本的な使用例
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
}));

上記のコードを見ていただくと分かる通り、たった数行でカウンターの状態管理が完成します。この簡潔さこそが、React Native 開発で Zustand が注目される理由なのです。

React Native での状態管理の課題

React Native でのモバイルアプリ開発では、Web アプリケーションとは異なる独特の課題があります。これらの課題を理解することで、なぜ Zustand が有効な解決策となるのかが見えてきます。

パフォーマンスの制約

モバイルデバイスは、デスクトップと比較してメモリや CPU リソースが限られています。そのため、状態管理ライブラリ選択時には以下の点を考慮する必要があります。

  • メモリ使用量の最適化
  • 不要な再レンダリングの抑制
  • バンドルサイズの最小化

ネイティブモジュールとの連携

React Native アプリでは、ネイティブ機能(カメラ、位置情報、プッシュ通知など)との連携が頻繁に発生します。これらの非同期処理を適切に状態管理に組み込む必要があります。

typescript// ネイティブモジュールとの連携例
import { create } from 'zustand';
import * as Location from 'expo-location';

interface LocationState {
  location: Location.LocationObject | null;
  loading: boolean;
  error: string | null;
  getCurrentLocation: () => Promise<void>;
}

const useLocationStore = create<LocationState>((set) => ({
  location: null,
  loading: false,
  error: null,
  getCurrentLocation: async () => {
    set({ loading: true, error: null });
    try {
      const location =
        await Location.getCurrentPositionAsync({});
      set({ location, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

このコードでは、位置情報の取得という非同期処理を、Zustand のストア内で自然に扱っています。エラーハンドリングやローディング状態の管理も一箇所に集約されているため、コンポーネント側での処理がシンプルになります。

永続化の必要性

モバイルアプリでは、アプリの再起動後も状態を保持する必要があるケースが多くあります。ユーザーの設定やログイン状態、未完了の作業などを適切に永続化する仕組みが求められます。

複数画面での状態共有

React Native のナビゲーション構造では、複数のスクリーン間で状態を共有する必要があります。特にタブナビゲーションやスタックナビゲーションを使用する場合、効率的な状態管理が重要になります。

これらの課題に対して、Zustand は軽量でありながら強力な解決策を提供してくれます。次のセクションでは、Zustand の基本概念について詳しく見ていきましょう。

Zustand の基本概念

Zustand の力を最大限に活用するためには、その基本概念を理解することが重要です。ここでは、実際の React Native 開発で使用する主要な概念を実例とともに解説していきます。

ストアの作成

Zustand では、create関数を使ってストアを作成します。ストアは状態とアクションを一箇所に集約する役割を果たします。

typescriptimport { create } from 'zustand';

// 基本的なストアの型定義
interface UserState {
  user: User | null;
  isAuthenticated: boolean;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  updateProfile: (profile: Partial<User>) => void;
}

// ストアの作成
const useUserStore = create<UserState>((set, get) => ({
  user: null,
  isAuthenticated: false,

  login: async (credentials) => {
    try {
      const user = await authService.login(credentials);
      set({ user, isAuthenticated: true });
    } catch (error) {
      throw error;
    }
  },

  logout: () => {
    set({ user: null, isAuthenticated: false });
  },

  updateProfile: (profile) => {
    const currentUser = get().user;
    if (currentUser) {
      set({ user: { ...currentUser, ...profile } });
    }
  },
}));

上記のコードでは、set関数を使って状態を更新し、get関数を使って現在の状態を取得しています。これらの関数は、Zustand が提供する基本的な API です。

コンポーネントでの使用

作成したストアは、React Native のコンポーネント内でフックとして使用できます。

typescriptimport React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useUserStore } from '../stores/userStore';

const ProfileScreen: React.FC = () => {
  const { user, isAuthenticated, logout } = useUserStore();

  if (!isAuthenticated) {
    return (
      <View>
        <Text>ログインが必要です</Text>
      </View>
    );
  }

  return (
    <View>
      <Text>ようこそ、{user?.name}さん</Text>
      <TouchableOpacity onPress={logout}>
        <Text>ログアウト</Text>
      </TouchableOpacity>
    </View>
  );
};

この例では、ストアから必要な状態とアクションのみを取得しています。Zustand は選択的な購読をサポートしているため、使用する状態のみを取得することで不要な再レンダリングを防げます。

選択的な購読

パフォーマンスを向上させるために、必要な状態のみを購読することが重要です。

typescript// 悪い例:全ての状態を購読
const {
  user,
  isAuthenticated,
  login,
  logout,
  updateProfile,
} = useUserStore();

// 良い例:必要な状態のみを購読
const user = useUserStore((state) => state.user);
const isAuthenticated = useUserStore(
  (state) => state.isAuthenticated
);
const logout = useUserStore((state) => state.logout);

選択的な購読により、userisAuthenticatedの状態が変更されたときのみコンポーネントが再レンダリングされます。これは、React Native アプリのパフォーマンス向上に直結する重要なテクニックです。

React Native プロジェクトでの導入

実際の React Native プロジェクトに Zustand を導入する手順を、段階的に説明していきます。

インストール

まず、Zustand をプロジェクトにインストールします。

bashyarn add zustand

TypeScript を使用している場合は、型定義も自動的にインストールされます。Zustand は最初から TypeScript サポートが組み込まれているため、追加の型定義パッケージは不要です。

プロジェクト構造の設計

効率的な開発のために、以下のようなディレクトリ構造を推奨します。

bashsrc/
├── stores/
│   ├── index.ts          # ストアの統合エクスポート
│   ├── userStore.ts      # ユーザー関連の状態管理
│   ├── appStore.ts       # アプリケーション全体の状態管理
│   └── dataStore.ts      # データ関連の状態管理
├── types/
│   └── store.ts          # ストア関連の型定義
└── utils/
    └── storage.ts        # 永続化用のユーティリティ

基本的なストアの実装

実際の React Native アプリでよく使用される、アプリケーション全体の状態を管理するストアを作成してみましょう。

typescript// src/stores/appStore.ts
import { create } from 'zustand';
import {
  createJSONStorage,
  persist,
} from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface AppState {
  // アプリケーションの設定
  theme: 'light' | 'dark';
  language: 'ja' | 'en';

  // ネットワーク状態
  isOnline: boolean;

  // ローディング状態
  isLoading: boolean;

  // アクション
  setTheme: (theme: 'light' | 'dark') => void;
  setLanguage: (language: 'ja' | 'en') => void;
  setOnlineStatus: (isOnline: boolean) => void;
  setLoading: (isLoading: boolean) => void;
}

export const useAppStore = create<AppState>()(
  persist(
    (set) => ({
      // 初期状態
      theme: 'light',
      language: 'ja',
      isOnline: true,
      isLoading: false,

      // アクション
      setTheme: (theme) => set({ theme }),
      setLanguage: (language) => set({ language }),
      setOnlineStatus: (isOnline) => set({ isOnline }),
      setLoading: (isLoading) => set({ isLoading }),
    }),
    {
      name: 'app-storage',
      storage: createJSONStorage(() => AsyncStorage),
      // 永続化したい状態のみを指定
      partialize: (state) => ({
        theme: state.theme,
        language: state.language,
      }),
    }
  )
);

このストアでは、persistミドルウェアを使用して、テーマと言語設定を永続化しています。AsyncStorageを使用することで、アプリの再起動後も設定が保持されます。

ストアの統合

複数のストアを効率的に管理するために、統合用のファイルを作成します。

typescript// src/stores/index.ts
export { useAppStore } from './appStore';
export { useUserStore } from './userStore';
export { useDataStore } from './dataStore';

// 型定義のエクスポート
export type { AppState } from './appStore';
export type { UserState } from './userStore';
export type { DataState } from './dataStore';

この統合により、他のファイルからストアを簡単にインポートできるようになります。

typescript// コンポーネントでの使用例
import { useAppStore, useUserStore } from '../stores';

const MyComponent: React.FC = () => {
  const theme = useAppStore((state) => state.theme);
  const user = useUserStore((state) => state.user);

  // コンポーネントの実装...
};

次のセクションでは、実際のアプリケーションでよく使用されるストアの設計パターンについて詳しく解説していきます。

ストアの設計パターン

効率的な React Native アプリケーションを構築するためには、適切なストア設計が不可欠です。ここでは、実際のプロジェクトで使用される代表的な設計パターンを紹介します。

機能別ストア分割パターン

大規模なアプリケーションでは、機能ごとにストアを分割することで保守性が向上します。

typescript// src/stores/authStore.ts
interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;

  // 認証関連のアクション
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  refreshToken: () => Promise<void>;
  clearError: () => void;
}

export const useAuthStore = create<AuthState>(
  (set, get) => ({
    user: null,
    token: null,
    isLoading: false,
    error: null,

    login: async (credentials) => {
      set({ isLoading: true, error: null });
      try {
        const response = await authAPI.login(credentials);
        set({
          user: response.user,
          token: response.token,
          isLoading: false,
        });
      } catch (error) {
        set({
          error: error.message,
          isLoading: false,
        });
      }
    },

    logout: () => {
      set({ user: null, token: null, error: null });
    },

    refreshToken: async () => {
      const { token } = get();
      if (!token) return;

      try {
        const newToken = await authAPI.refreshToken(token);
        set({ token: newToken });
      } catch (error) {
        // トークンの更新に失敗した場合はログアウト
        get().logout();
      }
    },

    clearError: () => set({ error: null }),
  })
);
typescript// src/stores/todoStore.ts
interface TodoState {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  isLoading: boolean;

  // Todo関連のアクション
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  deleteTodo: (id: string) => void;
  setFilter: (
    filter: 'all' | 'active' | 'completed'
  ) => void;
  loadTodos: () => Promise<void>;
}

export const useTodoStore = create<TodoState>(
  (set, get) => ({
    todos: [],
    filter: 'all',
    isLoading: false,

    addTodo: (text) => {
      const newTodo: Todo = {
        id: Date.now().toString(),
        text,
        completed: false,
        createdAt: new Date(),
      };
      set((state) => ({
        todos: [...state.todos, newTodo],
      }));
    },

    toggleTodo: (id) => {
      set((state) => ({
        todos: state.todos.map((todo) =>
          todo.id === id
            ? { ...todo, completed: !todo.completed }
            : todo
        ),
      }));
    },

    deleteTodo: (id) => {
      set((state) => ({
        todos: state.todos.filter((todo) => todo.id !== id),
      }));
    },

    setFilter: (filter) => set({ filter }),

    loadTodos: async () => {
      set({ isLoading: true });
      try {
        const todos = await todoAPI.getTodos();
        set({ todos, isLoading: false });
      } catch (error) {
        set({ isLoading: false });
      }
    },
  })
);

計算値とセレクターパターン

複雑な状態から派生する値を効率的に計算するパターンです。

typescript// src/stores/cartStore.ts
interface CartState {
  items: CartItem[];
  discountCode: string | null;

  // アクション
  addItem: (product: Product, quantity: number) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (
    productId: string,
    quantity: number
  ) => void;
  applyDiscount: (code: string) => void;

  // セレクター(計算値)
  getTotalItems: () => number;
  getTotalPrice: () => number;
  getDiscountedPrice: () => number;
}

export const useCartStore = create<CartState>(
  (set, get) => ({
    items: [],
    discountCode: null,

    addItem: (product, quantity) => {
      const existingItem = get().items.find(
        (item) => item.productId === product.id
      );

      if (existingItem) {
        set((state) => ({
          items: state.items.map((item) =>
            item.productId === product.id
              ? {
                  ...item,
                  quantity: item.quantity + quantity,
                }
              : item
          ),
        }));
      } else {
        const newItem: CartItem = {
          productId: product.id,
          name: product.name,
          price: product.price,
          quantity,
        };
        set((state) => ({
          items: [...state.items, newItem],
        }));
      }
    },

    removeItem: (productId) => {
      set((state) => ({
        items: state.items.filter(
          (item) => item.productId !== productId
        ),
      }));
    },

    updateQuantity: (productId, quantity) => {
      if (quantity <= 0) {
        get().removeItem(productId);
        return;
      }

      set((state) => ({
        items: state.items.map((item) =>
          item.productId === productId
            ? { ...item, quantity }
            : item
        ),
      }));
    },

    applyDiscount: (code) => set({ discountCode: code }),

    // 計算値を返すメソッド
    getTotalItems: () => {
      return get().items.reduce(
        (total, item) => total + item.quantity,
        0
      );
    },

    getTotalPrice: () => {
      return get().items.reduce(
        (total, item) => total + item.price * item.quantity,
        0
      );
    },

    getDiscountedPrice: () => {
      const totalPrice = get().getTotalPrice();
      const discountCode = get().discountCode;

      // 割引コードに応じた計算
      if (discountCode === 'SAVE10') {
        return totalPrice * 0.9;
      }
      return totalPrice;
    },
  })
);

非同期処理パターン

API 呼び出しやデータ取得を含む非同期処理のパターンです。

typescript// src/stores/newsStore.ts
interface NewsState {
  articles: Article[];
  categories: Category[];
  selectedCategory: string | null;
  isLoading: boolean;
  error: string | null;
  hasMore: boolean;
  page: number;

  // 非同期アクション
  fetchArticles: (category?: string) => Promise<void>;
  fetchMoreArticles: () => Promise<void>;
  refreshArticles: () => Promise<void>;
  selectCategory: (categoryId: string) => void;
}

export const useNewsStore = create<NewsState>(
  (set, get) => ({
    articles: [],
    categories: [],
    selectedCategory: null,
    isLoading: false,
    error: null,
    hasMore: true,
    page: 1,

    fetchArticles: async (category) => {
      set({ isLoading: true, error: null });

      try {
        const response = await newsAPI.getArticles({
          category,
          page: 1,
          limit: 20,
        });

        set({
          articles: response.articles,
          hasMore: response.hasMore,
          page: 1,
          isLoading: false,
        });
      } catch (error) {
        set({
          error: error.message,
          isLoading: false,
        });
      }
    },

    fetchMoreArticles: async () => {
      const { hasMore, isLoading, page, selectedCategory } =
        get();

      if (!hasMore || isLoading) return;

      set({ isLoading: true });

      try {
        const response = await newsAPI.getArticles({
          category: selectedCategory,
          page: page + 1,
          limit: 20,
        });

        set((state) => ({
          articles: [
            ...state.articles,
            ...response.articles,
          ],
          hasMore: response.hasMore,
          page: state.page + 1,
          isLoading: false,
        }));
      } catch (error) {
        set({
          error: error.message,
          isLoading: false,
        });
      }
    },

    refreshArticles: async () => {
      const { selectedCategory } = get();
      await get().fetchArticles(selectedCategory);
    },

    selectCategory: (categoryId) => {
      set({ selectedCategory: categoryId });
      get().fetchArticles(categoryId);
    },
  })
);

パフォーマンス最適化

React Native アプリケーションにおいて、Zustand を使用した状態管理のパフォーマンスを最適化するための実践的なテクニックを解説します。

選択的購読の活用

最も重要な最適化は、必要な状態のみを購読することです。

typescript// 悪い例:全ての状態を購読
const TodoList: React.FC = () => {
  const {
    todos,
    filter,
    isLoading,
    addTodo,
    toggleTodo,
    setFilter,
  } = useTodoStore();

  return (
    <FlatList
      data={todos}
      renderItem={({ item }) => (
        <TodoItem
          todo={item}
          onToggle={() => toggleTodo(item.id)}
        />
      )}
    />
  );
};

// 良い例:必要な状態のみを購読
const TodoList: React.FC = () => {
  const todos = useTodoStore((state) => state.todos);
  const toggleTodo = useTodoStore(
    (state) => state.toggleTodo
  );

  return (
    <FlatList
      data={todos}
      renderItem={({ item }) => (
        <TodoItem
          todo={item}
          onToggle={() => toggleTodo(item.id)}
        />
      )}
    />
  );
};

メモ化による最適化

計算コストの高い処理には、メモ化を活用します。

typescriptimport { useMemo } from 'react';

const TodoList: React.FC = () => {
  const todos = useTodoStore((state) => state.todos);
  const filter = useTodoStore((state) => state.filter);

  // フィルタリングされたTodoリストをメモ化
  const filteredTodos = useMemo(() => {
    switch (filter) {
      case 'active':
        return todos.filter((todo) => !todo.completed);
      case 'completed':
        return todos.filter((todo) => todo.completed);
      default:
        return todos;
    }
  }, [todos, filter]);

  return (
    <FlatList
      data={filteredTodos}
      renderItem={({ item }) => <TodoItem todo={item} />}
    />
  );
};

浅い比較による最適化

オブジェクトや配列の比較には、浅い比較を使用します。

typescriptimport { shallow } from 'zustand/shallow';

const UserProfile: React.FC = () => {
  // 浅い比較を使用してオブジェクトの変更を検知
  const { name, email, avatar } = useUserStore(
    (state) => ({
      name: state.user?.name,
      email: state.user?.email,
      avatar: state.user?.avatar,
    }),
    shallow
  );

  return (
    <View>
      <Text>{name}</Text>
      <Text>{email}</Text>
      <Image source={{ uri: avatar }} />
    </View>
  );
};

状態の正規化

複雑なデータ構造は正規化することで、パフォーマンスが向上します。

typescript// 悪い例:ネストしたデータ構造
interface BadState {
  posts: {
    id: string;
    title: string;
    author: {
      id: string;
      name: string;
      posts: Post[]; // 循環参照
    };
    comments: Comment[];
  }[];
}

// 良い例:正規化されたデータ構造
interface GoodState {
  posts: Record<string, Post>;
  authors: Record<string, Author>;
  comments: Record<string, Comment>;
  postIds: string[];
}

const usePostStore = create<GoodState>((set, get) => ({
  posts: {},
  authors: {},
  comments: {},
  postIds: [],

  addPost: (post: Post) => {
    set((state) => ({
      posts: { ...state.posts, [post.id]: post },
      postIds: [...state.postIds, post.id],
    }));
  },

  updatePost: (id: string, updates: Partial<Post>) => {
    set((state) => ({
      posts: {
        ...state.posts,
        [id]: { ...state.posts[id], ...updates },
      },
    }));
  },
}));

バッチ更新の活用

複数の状態更新を一度に行うことで、再レンダリングを最小限に抑えます。

typescriptconst useDataStore = create<DataState>((set) => ({
  data: [],
  isLoading: false,
  error: null,

  // 悪い例:複数回の状態更新
  fetchDataBad: async () => {
    set({ isLoading: true });
    set({ error: null });

    try {
      const data = await api.getData();
      set({ data });
      set({ isLoading: false });
    } catch (error) {
      set({ error: error.message });
      set({ isLoading: false });
    }
  },

  // 良い例:バッチ更新
  fetchDataGood: async () => {
    set({ isLoading: true, error: null });

    try {
      const data = await api.getData();
      set({ data, isLoading: false });
    } catch (error) {
      set({ error: error.message, isLoading: false });
    }
  },
}));

これらの最適化テクニックを適切に活用することで、React Native アプリケーションのパフォーマンスを大幅に向上させることができます。次のセクションでは、開発効率を向上させるデバッグツールについて詳しく見ていきましょう。

デバッグと開発者ツール

React Native 開発において、効率的なデバッグ環境の構築は開発速度に大きく影響します。Zustand は優れたデバッグサポートを提供しており、これらのツールを活用することで開発効率を大幅に向上させることができます。

Redux DevTools 連携

Zustand は Redux DevTools と連携することができ、状態の変更履歴を視覚的に確認できます。

typescript// src/stores/debugStore.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

interface DebugState {
  count: number;
  user: User | null;
  increment: () => void;
  decrement: () => void;
  setUser: (user: User) => void;
}

export const useDebugStore = create<DebugState>()(
  devtools(
    (set) => ({
      count: 0,
      user: null,

      increment: () =>
        set(
          (state) => ({ count: state.count + 1 }),
          false,
          'increment'
        ),
      decrement: () =>
        set(
          (state) => ({ count: state.count - 1 }),
          false,
          'decrement'
        ),
      setUser: (user) => set({ user }, false, 'setUser'),
    }),
    {
      name: 'debug-store', // DevToolsでの表示名
      serialize: true, // 状態のシリアライズ
    }
  )
);

DevTools を使用することで、以下の機能を活用できます:

機能説明
状態の履歴全ての状態変更を時系列で確認
アクションの追跡どのアクションが実行されたかを確認
タイムトラベル過去の状態に戻って確認
状態の編集開発中に状態を直接編集

ログ出力による状態監視

開発中に状態変更をログ出力することで、問題の早期発見が可能になります。

typescript// src/stores/logStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';

interface LogState {
  data: any[];
  loading: boolean;
  error: string | null;

  fetchData: () => Promise<void>;
  clearError: () => void;
}

export const useLogStore = create<LogState>()(
  subscribeWithSelector((set, get) => ({
    data: [],
    loading: false,
    error: null,

    fetchData: async () => {
      console.log('🔄 Data fetch started');
      set({ loading: true, error: null });

      try {
        const data = await api.getData();
        console.log(
          '✅ Data fetch successful:',
          data.length,
          'items'
        );
        set({ data, loading: false });
      } catch (error) {
        console.error(
          '❌ Data fetch failed:',
          error.message
        );
        set({ error: error.message, loading: false });
      }
    },

    clearError: () => {
      console.log('🧹 Error cleared');
      set({ error: null });
    },
  }))
);

// 状態変更の監視
useLogStore.subscribe(
  (state) => state.loading,
  (loading) => {
    console.log('Loading state changed:', loading);
  }
);

useLogStore.subscribe(
  (state) => state.error,
  (error) => {
    if (error) {
      console.error('Error state updated:', error);
    }
  }
);

カスタムデバッグミドルウェア

プロジェクト固有のデバッグ要件に対応するため、カスタムミドルウェアを作成できます。

typescript// src/utils/debugMiddleware.ts
import { StateCreator } from 'zustand';

// パフォーマンス監視ミドルウェア
export const performanceMiddleware =
  <T>(f: StateCreator<T>, name: string): StateCreator<T> =>
  (set, get, api) => {
    const performanceSet = (
      partial: any,
      replace?: boolean,
      actionName?: string
    ) => {
      const start = performance.now();

      set(partial, replace);

      const end = performance.now();
      console.log(
        `⚡ ${name} - ${actionName || 'unknown'}: ${(
          end - start
        ).toFixed(2)}ms`
      );
    };

    return f(performanceSet, get, api);
  };

// エラー監視ミドルウェア
export const errorMiddleware =
  <T>(f: StateCreator<T>): StateCreator<T> =>
  (set, get, api) => {
    const errorSet = (
      partial: any,
      replace?: boolean,
      actionName?: string
    ) => {
      try {
        set(partial, replace);
      } catch (error) {
        console.error(
          `💥 State update error in ${actionName}:`,
          error
        );
        // エラーレポーティングサービスに送信
        // crashlytics.recordError(error)
        throw error;
      }
    };

    return f(errorSet, get, api);
  };

// 使用例
const useMonitoredStore = create<State>()(
  performanceMiddleware(
    errorMiddleware((set) => ({
      // ストアの実装
    })),
    'monitored-store'
  )
);

React Native Debugger 連携

React Native Debugger を使用することで、より詳細なデバッグが可能になります。

typescript// src/utils/debugConfig.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

// 開発環境でのみDevToolsを有効化
const withDevtools = __DEV__ ? devtools : (f: any) => f;

export const createDebugStore = <T>(
  storeCreator: any,
  name: string
) => {
  return create<T>()(
    withDevtools(storeCreator, {
      name,
      serialize: {
        // 大きなオブジェクトのシリアライズを制限
        options: {
          maxDepth: 3,
          maxLength: 100,
        },
      },
    })
  );
};

実践的なサンプルアプリ

ここでは、実際のモバイルアプリでよく使用される機能を組み合わせた、実践的なサンプルアプリケーションを構築してみます。天気予報アプリを例に、Zustand を活用した状態管理の実装を詳しく解説します。

アプリケーション概要

作成する天気予報アプリの主な機能:

  • 現在地の天気情報取得
  • 都市検索と追加
  • お気に入り都市の管理
  • 天気予報の表示
  • オフライン対応

ストア設計

まず、アプリケーション全体の状態管理を設計します。

typescript// src/stores/weatherStore.ts
import { create } from 'zustand';
import {
  persist,
  createJSONStorage,
} from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface WeatherData {
  id: string;
  cityName: string;
  temperature: number;
  description: string;
  humidity: number;
  windSpeed: number;
  icon: string;
  timestamp: number;
}

interface WeatherState {
  // 状態
  currentWeather: WeatherData | null;
  favoriteCities: string[];
  weatherCache: Record<string, WeatherData>;
  isLoading: boolean;
  error: string | null;
  lastUpdate: number;

  // アクション
  fetchCurrentWeather: (
    lat: number,
    lon: number
  ) => Promise<void>;
  fetchCityWeather: (cityName: string) => Promise<void>;
  addFavoriteCity: (cityName: string) => void;
  removeFavoriteCity: (cityName: string) => void;
  clearError: () => void;
  refreshWeather: () => Promise<void>;
}

export const useWeatherStore = create<WeatherState>()(
  persist(
    (set, get) => ({
      // 初期状態
      currentWeather: null,
      favoriteCities: [],
      weatherCache: {},
      isLoading: false,
      error: null,
      lastUpdate: 0,

      // 現在地の天気を取得
      fetchCurrentWeather: async (lat, lon) => {
        set({ isLoading: true, error: null });

        try {
          const weather =
            await weatherAPI.getCurrentWeather(lat, lon);
          const weatherData: WeatherData = {
            id: `current-${Date.now()}`,
            cityName: weather.name,
            temperature: Math.round(weather.main.temp),
            description: weather.weather[0].description,
            humidity: weather.main.humidity,
            windSpeed: weather.wind.speed,
            icon: weather.weather[0].icon,
            timestamp: Date.now(),
          };

          set({
            currentWeather: weatherData,
            weatherCache: {
              ...get().weatherCache,
              [weatherData.cityName]: weatherData,
            },
            isLoading: false,
            lastUpdate: Date.now(),
          });
        } catch (error) {
          set({
            error: error.message,
            isLoading: false,
          });
        }
      },

      // 都市名で天気を取得
      fetchCityWeather: async (cityName) => {
        set({ isLoading: true, error: null });

        try {
          const weather = await weatherAPI.getCityWeather(
            cityName
          );
          const weatherData: WeatherData = {
            id: `${cityName}-${Date.now()}`,
            cityName: weather.name,
            temperature: Math.round(weather.main.temp),
            description: weather.weather[0].description,
            humidity: weather.main.humidity,
            windSpeed: weather.wind.speed,
            icon: weather.weather[0].icon,
            timestamp: Date.now(),
          };

          set({
            weatherCache: {
              ...get().weatherCache,
              [cityName]: weatherData,
            },
            isLoading: false,
          });
        } catch (error) {
          set({
            error: error.message,
            isLoading: false,
          });
        }
      },

      // お気に入り都市の追加
      addFavoriteCity: (cityName) => {
        const { favoriteCities } = get();
        if (!favoriteCities.includes(cityName)) {
          set({
            favoriteCities: [...favoriteCities, cityName],
          });
          // 天気情報も取得
          get().fetchCityWeather(cityName);
        }
      },

      // お気に入り都市の削除
      removeFavoriteCity: (cityName) => {
        set({
          favoriteCities: get().favoriteCities.filter(
            (city) => city !== cityName
          ),
        });
      },

      // エラーのクリア
      clearError: () => set({ error: null }),

      // 天気情報の更新
      refreshWeather: async () => {
        const { currentWeather, favoriteCities } = get();

        // 現在地の天気を更新(位置情報が必要)
        if (currentWeather) {
          // 位置情報を再取得して更新
          // この実装では簡略化
        }

        // お気に入り都市の天気を更新
        const updatePromises = favoriteCities.map((city) =>
          get().fetchCityWeather(city)
        );

        await Promise.all(updatePromises);
      },
    }),
    {
      name: 'weather-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({
        favoriteCities: state.favoriteCities,
        weatherCache: state.weatherCache,
      }),
    }
  )
);

位置情報管理ストア

位置情報の取得と管理を行うストアを作成します。

typescript// src/stores/locationStore.ts
import { create } from 'zustand';
import * as Location from 'expo-location';

interface LocationState {
  location: Location.LocationObject | null;
  isLoading: boolean;
  error: string | null;
  permissionGranted: boolean;

  requestPermission: () => Promise<boolean>;
  getCurrentLocation: () => Promise<void>;
  clearError: () => void;
}

export const useLocationStore = create<LocationState>(
  (set, get) => ({
    location: null,
    isLoading: false,
    error: null,
    permissionGranted: false,

    requestPermission: async () => {
      try {
        const { status } =
          await Location.requestForegroundPermissionsAsync();
        const granted = status === 'granted';
        set({ permissionGranted: granted });
        return granted;
      } catch (error) {
        set({ error: error.message });
        return false;
      }
    },

    getCurrentLocation: async () => {
      set({ isLoading: true, error: null });

      try {
        const hasPermission =
          get().permissionGranted ||
          (await get().requestPermission());

        if (!hasPermission) {
          throw new Error('位置情報の許可が必要です');
        }

        const location =
          await Location.getCurrentPositionAsync({
            accuracy: Location.Accuracy.Balanced,
          });

        set({ location, isLoading: false });
      } catch (error) {
        set({ error: error.message, isLoading: false });
      }
    },

    clearError: () => set({ error: null }),
  })
);

メインコンポーネントの実装

作成したストアを使用するメインコンポーネントを実装します。

typescript// src/components/WeatherApp.tsx
import React, { useEffect } from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  FlatList,
  StyleSheet,
  Alert,
  RefreshControl,
} from 'react-native';
import { useWeatherStore } from '../stores/weatherStore';
import { useLocationStore } from '../stores/locationStore';

const WeatherApp: React.FC = () => {
  const {
    currentWeather,
    favoriteCities,
    weatherCache,
    isLoading,
    error,
    fetchCurrentWeather,
    addFavoriteCity,
    removeFavoriteCity,
    clearError,
    refreshWeather,
  } = useWeatherStore();

  const {
    location,
    isLoading: locationLoading,
    error: locationError,
    getCurrentLocation,
    clearError: clearLocationError,
  } = useLocationStore();

  useEffect(() => {
    // アプリ起動時に現在地を取得
    getCurrentLocation();
  }, []);

  useEffect(() => {
    // 位置情報が取得できたら天気を取得
    if (location) {
      fetchCurrentWeather(
        location.coords.latitude,
        location.coords.longitude
      );
    }
  }, [location]);

  useEffect(() => {
    // エラーが発生した場合の処理
    if (error) {
      Alert.alert('エラー', error, [
        { text: 'OK', onPress: clearError },
      ]);
    }

    if (locationError) {
      Alert.alert('位置情報エラー', locationError, [
        { text: 'OK', onPress: clearLocationError },
      ]);
    }
  }, [error, locationError]);

  const handleAddCity = () => {
    Alert.prompt(
      '都市を追加',
      '都市名を入力してください',
      (cityName) => {
        if (cityName) {
          addFavoriteCity(cityName);
        }
      }
    );
  };

  const handleRemoveCity = (cityName: string) => {
    Alert.alert(
      '都市を削除',
      `${cityName}をお気に入りから削除しますか?`,
      [
        { text: 'キャンセル', style: 'cancel' },
        {
          text: '削除',
          onPress: () => removeFavoriteCity(cityName),
        },
      ]
    );
  };

  const renderWeatherItem = ({
    item,
  }: {
    item: string;
  }) => {
    const weather = weatherCache[item];

    if (!weather) {
      return (
        <View style={styles.weatherItem}>
          <Text>{item} - 読み込み中...</Text>
        </View>
      );
    }

    return (
      <View style={styles.weatherItem}>
        <View style={styles.weatherInfo}>
          <Text style={styles.cityName}>
            {weather.cityName}
          </Text>
          <Text style={styles.temperature}>
            {weather.temperature}°C
          </Text>
          <Text style={styles.description}>
            {weather.description}
          </Text>
        </View>
        <TouchableOpacity
          style={styles.removeButton}
          onPress={() => handleRemoveCity(item)}
        >
          <Text style={styles.removeButtonText}>削除</Text>
        </TouchableOpacity>
      </View>
    );
  };

  return (
    <View style={styles.container}>
      {/* 現在地の天気 */}
      <View style={styles.currentWeatherContainer}>
        <Text style={styles.sectionTitle}>
          現在地の天気
        </Text>
        {currentWeather ? (
          <View style={styles.currentWeather}>
            <Text style={styles.currentCity}>
              {currentWeather.cityName}
            </Text>
            <Text style={styles.currentTemp}>
              {currentWeather.temperature}°C
            </Text>
            <Text style={styles.currentDesc}>
              {currentWeather.description}
            </Text>
          </View>
        ) : (
          <Text>位置情報を取得中...</Text>
        )}
      </View>

      {/* お気に入り都市 */}
      <View style={styles.favoritesContainer}>
        <View style={styles.favoritesHeader}>
          <Text style={styles.sectionTitle}>
            お気に入りの都市
          </Text>
          <TouchableOpacity
            style={styles.addButton}
            onPress={handleAddCity}
          >
            <Text style={styles.addButtonText}>追加</Text>
          </TouchableOpacity>
        </View>

        <FlatList
          data={favoriteCities}
          renderItem={renderWeatherItem}
          keyExtractor={(item) => item}
          refreshControl={
            <RefreshControl
              refreshing={isLoading || locationLoading}
              onRefresh={refreshWeather}
            />
          }
          ListEmptyComponent={
            <Text style={styles.emptyText}>
              お気に入りの都市がありません
            </Text>
          }
        />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
    padding: 16,
  },
  currentWeatherContainer: {
    backgroundColor: '#fff',
    borderRadius: 8,
    padding: 16,
    marginBottom: 16,
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
  },
  sectionTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 12,
    color: '#333',
  },
  currentWeather: {
    alignItems: 'center',
  },
  currentCity: {
    fontSize: 16,
    color: '#666',
    marginBottom: 4,
  },
  currentTemp: {
    fontSize: 32,
    fontWeight: 'bold',
    color: '#333',
    marginBottom: 4,
  },
  currentDesc: {
    fontSize: 14,
    color: '#666',
    textTransform: 'capitalize',
  },
  favoritesContainer: {
    flex: 1,
    backgroundColor: '#fff',
    borderRadius: 8,
    padding: 16,
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
  },
  favoritesHeader: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    marginBottom: 12,
  },
  addButton: {
    backgroundColor: '#007AFF',
    paddingHorizontal: 12,
    paddingVertical: 6,
    borderRadius: 4,
  },
  addButtonText: {
    color: '#fff',
    fontSize: 14,
    fontWeight: '500',
  },
  weatherItem: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#eee',
  },
  weatherInfo: {
    flex: 1,
  },
  cityName: {
    fontSize: 16,
    fontWeight: '500',
    color: '#333',
    marginBottom: 2,
  },
  temperature: {
    fontSize: 14,
    color: '#666',
    marginBottom: 2,
  },
  description: {
    fontSize: 12,
    color: '#999',
    textTransform: 'capitalize',
  },
  removeButton: {
    backgroundColor: '#FF3B30',
    paddingHorizontal: 8,
    paddingVertical: 4,
    borderRadius: 4,
  },
  removeButtonText: {
    color: '#fff',
    fontSize: 12,
  },
  emptyText: {
    textAlign: 'center',
    color: '#999',
    marginTop: 20,
  },
});

export default WeatherApp;

このサンプルアプリでは、以下の Zustand の機能を活用しています:

  • 永続化: お気に入り都市と天気キャッシュの保存
  • 非同期処理: API 呼び出しの管理
  • エラーハンドリング: 適切なエラー状態の管理
  • 選択的購読: 必要な状態のみの取得
  • 複数ストア: 機能別のストア分割

これらの実装パターンは、実際の React Native アプリケーション開発で直接活用できる実践的な内容となっています。

まとめ

React Native でのモバイルアプリ開発において、Zustand は軽量でありながら強力な状態管理ライブラリとして、開発効率とアプリケーションのパフォーマンス向上に大きく貢献してくれます。

主要なメリット

今回の記事を通じて、Zustand の以下のメリットを実感していただけたのではないでしょうか。

シンプルさと学習コストの低さ Redux のような複雑な設定やボイラープレートコードが不要で、直感的な API で状態管理を実装できます。特に React Native 開発者にとって、すぐに習得できる点は大きな魅力ですね。

優れたパフォーマンス 選択的購読や浅い比較機能により、不要な再レンダリングを効果的に防ぎ、モバイルアプリに求められる高いパフォーマンスを実現できます。

TypeScript 完全対応 型安全性を保ちながら開発できるため、大規模なアプリケーションでも安心して使用できます。開発時の生産性向上にも大きく貢献します。

充実したミドルウェア 永続化、デバッグ、非同期処理など、実際のアプリケーション開発で必要となる機能が豊富に用意されています。

実践での活用ポイント

実際のプロジェクトで Zustand を活用する際は、以下のポイントを意識することで、より効果的な状態管理が実現できます:

  1. 適切なストア分割: 機能ごとにストアを分割し、保守性を向上させる
  2. 選択的購読の活用: 必要な状態のみを購読してパフォーマンスを最適化
  3. 永続化の戦略的活用: ユーザー体験を向上させるために適切な状態を永続化
  4. エラーハンドリングの徹底: 堅牢なアプリケーションを構築するための適切なエラー処理
  5. デバッグツールの活用: 開発効率を向上させるためのツール活用

今後の展望

Zustand は継続的に進化を続けており、React Native エコシステムとの連携もますます強化されています。新しい機能やベストプラクティスを継続的に学習し、プロジェクトに活用していくことで、より良いモバイルアプリケーションを構築できるでしょう。

モバイルアプリ開発における状態管理は、ユーザー体験の向上に直結する重要な要素です。Zustand の持つシンプルさと強力さを活用して、効率的で保守性の高い React Native アプリケーションを開発していきましょう。

皆さんのプロジェクトでも、ぜひ Zustand を活用してみてください。きっと開発体験の向上を実感していただけるはずです。

関連リンク

公式ドキュメント

開発ツール

関連ライブラリ

学習リソース

コミュニティ