T-CREATOR

UI コンポーネントと Zustand の最適な依存関係の持たせ方

UI コンポーネントと Zustand の最適な依存関係の持たせ方

React アプリケーションの開発において、状態管理はアプリケーションの複雑さが増すにつれて重要な課題となります。従来の Redux や ContextAPI とは異なる軽量なアプローチを提供する Zustand が注目を集めていますが、UI コンポーネントとの適切な依存関係を構築することで、より保守性の高い、パフォーマンスに優れたアプリケーションを作ることができます。本記事では、実際のコード例とともに、Zustand を使った最適な設計パターンについて詳しく解説いたします。

背景

React 状態管理の複雑化

現代の React アプリケーションでは、コンポーネント間でのデータの共有が複雑になりがちです。特に、以下のような課題が頻繁に発生します。

props drilling(プロップスのバケツリレー)による可読性の低下や、複数のコンポーネントが同じ状態を必要とする場合の管理の困難さが挙げられます。従来の ContextAPI を使用した場合、以下のような問題が発生することがあります。

typescript// 従来のContextAPIでの問題例
const UserContext = createContext();
const ThemeContext = createContext();
const CartContext = createContext();

// 複数のProviderが必要になり、ネストが深くなる
function App() {
  return (
    <UserProvider>
      <ThemeProvider>
        <CartProvider>
          <MainApp />
        </CartProvider>
      </ThemeProvider>
    </UserProvider>
  );
}

このような構造では、状態の更新時に不要な再レンダリングが発生し、パフォーマンスの問題を引き起こす可能性があります。

Zustand の登場と特徴

Zustand は、ドイツ語で「状態」を意味するライブラリで、React 状態管理の新しいアプローチを提供します。その特徴は以下の通りです。

特徴説明
軽量性バンドルサイズが小さく、学習コストが低い
直感的な APIuseStore フックを使用したシンプルな状態管理
TypeScript 対応型安全な状態管理が可能
パフォーマンス必要な部分のみの再レンダリングが可能

UI コンポーネントとの関係性

Zustand の最大の特徴は、UI コンポーネントとの疎結合な関係を保ちながら、効率的な状態管理を実現できることです。これにより、コンポーネントの責務が明確になり、テストの書きやすさも向上します。

課題

コンポーネントの責務分離

React 開発において、コンポーネントの責務が曖昧になることは避けたい問題です。以下のような問題が発生しがちです。

typescript// 悪い例:責務が混在したコンポーネント
function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // APIコール、状態管理、UI描画が混在
  const fetchUser = async () => {
    setLoading(true);
    try {
      const response = await api.getUser();
      setUser(response.data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {loading && <div>Loading...</div>}
      {error && <div>Error: {error}</div>}
      {user && <div>{user.name}</div>}
    </div>
  );
}

このような実装では、コンポーネントが複数の責務を持つことになり、保守性が低下します。

状態の依存関係の複雑化

複数のコンポーネントが同じ状態に依存する場合、以下のような問題が発生します。

typescript// 状態の依存関係が複雑になる例
function Header() {
  const [cartItems, setCartItems] = useState([]);
  // カート数の表示
  return <div>Cart: {cartItems.length}</div>;
}

function ProductList() {
  const [cartItems, setCartItems] = useState([]);
  // 商品をカートに追加
  const addToCart = (product) => {
    setCartItems([...cartItems, product]);
  };
  // ...
}

このような状態の重複は、データの整合性を保つのが困難になり、バグの原因となります。

パフォーマンス問題

不適切な状態管理により、以下のようなパフォーマンス問題が発生することがあります。

typescript// パフォーマンスの問題を引き起こす例
function ExpensiveComponent() {
  const [globalState, setGlobalState] =
    useContext(GlobalContext);

  // globalStateのすべての変更で再レンダリングされる
  return (
    <div>
      <ExpensiveCalculation data={globalState.someData} />
    </div>
  );
}

このような実装では、関係のない状態変更でも再レンダリングが発生し、アプリケーションの応答性が低下します。

解決策

Zustand の適切な設計パターン

Zustand を使用することで、これらの課題を効果的に解決できます。まず、基本的なストアの作成から始めましょう。

typescript// 基本的なZustandストアの作成
import { create } from 'zustand';

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: () => Promise<void>;
  clearError: () => void;
}

const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  error: null,
  fetchUser: async () => {
    set({ loading: true, error: null });
    try {
      const response = await api.getUser();
      set({ user: response.data, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
  clearError: () => set({ error: null }),
}));

このストアは、ユーザー関連の状態とアクションを一箇所で管理し、責務を明確に分離しています。

コンポーネントとストアの分離

次に、コンポーネントではストアから必要な状態のみを取得し、UI の描画に専念します。

typescript// UIコンポーネントの実装
function UserProfile() {
  const { user, loading, error, fetchUser, clearError } =
    useUserStore();

  useEffect(() => {
    if (!user) {
      fetchUser();
    }
  }, [user, fetchUser]);

  if (loading) return <LoadingSpinner />;
  if (error)
    return (
      <ErrorMessage error={error} onClear={clearError} />
    );
  if (!user) return <div>No user data</div>;

  return (
    <div className='user-profile'>
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

このように、コンポーネントは状態管理の詳細を知る必要がなく、UI の描画に集中できます。

最適な依存関係の構築方法

Zustand の強力な機能の一つは、セレクターを使用した部分的な状態の購読です。これにより、必要な部分のみの再レンダリングを実現できます。

typescript// セレクターを使用した部分的な状態の購読
function UserName() {
  // userの名前のみを購読
  const userName = useUserStore(
    (state) => state.user?.name
  );

  return <span>{userName}</span>;
}

function UserEmail() {
  // userのメールアドレスのみを購読
  const userEmail = useUserStore(
    (state) => state.user?.email
  );

  return <span>{userEmail}</span>;
}

このような実装により、user.nameが変更されてもUserEmailコンポーネントは再レンダリングされず、パフォーマンスが向上します。

具体例

実装サンプルコード

実際のアプリケーションでよく見られるショッピングカートの実装を例に、最適な依存関係の構築方法を見てみましょう。

まず、カート機能に必要な状態とアクションを定義します。

typescript// カートストアの定義
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  total: number;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
}

const useCartStore = create<CartState>((set, get) => ({
  items: [],
  total: 0,
  addItem: (item) => {
    const currentItems = get().items;
    const existingItem = currentItems.find(
      (i) => i.id === item.id
    );

    if (existingItem) {
      set({
        items: currentItems.map((i) =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        ),
      });
    } else {
      set({
        items: [...currentItems, { ...item, quantity: 1 }],
      });
    }

    // 合計金額の計算
    const newTotal = get().items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    set({ total: newTotal });
  },
  removeItem: (id) => {
    set({
      items: get().items.filter((item) => item.id !== id),
    });

    const newTotal = get().items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    set({ total: newTotal });
  },
  updateQuantity: (id, quantity) => {
    if (quantity <= 0) {
      get().removeItem(id);
      return;
    }

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

    const newTotal = get().items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    set({ total: newTotal });
  },
  clearCart: () => set({ items: [], total: 0 }),
}));

次に、このストアを使用するコンポーネントを実装します。

typescript// 商品リストコンポーネント
function ProductList() {
  const addItem = useCartStore((state) => state.addItem);

  return (
    <div className='product-list'>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={() => addItem(product)}
        />
      ))}
    </div>
  );
}

// カートアイコンコンポーネント(カートアイテム数のみ購読)
function CartIcon() {
  const itemCount = useCartStore((state) =>
    state.items.reduce(
      (sum, item) => sum + item.quantity,
      0
    )
  );

  return (
    <div className='cart-icon'>
      🛒 {itemCount > 0 && <span>{itemCount}</span>}
    </div>
  );
}

// カート合計金額コンポーネント(合計金額のみ購読)
function CartTotal() {
  const total = useCartStore((state) => state.total);

  return (
    <div className='cart-total'>
      合計: ¥{total.toLocaleString()}
    </div>
  );
}

ベストプラクティス

以下は、Zustand を使用する際のベストプラクティスです。

1. 状態の正規化

typescript// 推奨:正規化された状態構造
interface NormalizedState {
  users: Record<string, User>;
  posts: Record<string, Post>;
  userIds: string[];
  postIds: string[];
}

const useDataStore = create<NormalizedState>((set) => ({
  users: {},
  posts: {},
  userIds: [],
  postIds: [],
  // ...
}));

2. カスタムフックによる抽象化

typescript// カスタムフックでストアの使用を抽象化
function useUser(userId: string) {
  const user = useUserStore((state) => state.users[userId]);
  const fetchUser = useUserStore(
    (state) => state.fetchUser
  );

  useEffect(() => {
    if (!user) {
      fetchUser(userId);
    }
  }, [user, userId, fetchUser]);

  return user;
}

// 使用例
function UserProfile({ userId }: { userId: string }) {
  const user = useUser(userId);

  if (!user) return <div>Loading...</div>;

  return <div>{user.name}</div>;
}

3. エラーハンドリングパターン

typescript// エラーハンドリングを含むストア
const useApiStore = create<ApiState>((set) => ({
  data: null,
  loading: false,
  error: null,
  fetchData: async () => {
    set({ loading: true, error: null });
    try {
      const response = await api.getData();
      set({ data: response.data, loading: false });
    } catch (error) {
      // 具体的なエラーメッセージを保存
      if (error.response?.status === 404) {
        set({
          error: 'データが見つかりません',
          loading: false,
        });
      } else if (error.response?.status === 500) {
        set({
          error: 'サーバーエラーが発生しました',
          loading: false,
        });
      } else {
        set({
          error: 'ネットワークエラーが発生しました',
          loading: false,
        });
      }
    }
  },
}));

アンチパターンの回避

以下は、避けるべきアンチパターンです。

1. 過度に細かい状態分割

typescript// 悪い例:過度に細かい状態分割
const useUserNameStore = create(() => ({ name: '' }));
const useUserEmailStore = create(() => ({ email: '' }));
const useUserAgeStore = create(() => ({ age: 0 }));

// 良い例:関連する状態をまとめる
const useUserStore = create(() => ({
  name: '',
  email: '',
  age: 0,
}));

2. ストア内での複雑なロジック

typescript// 悪い例:ストア内での複雑なビジネスロジック
const useComplexStore = create((set) => ({
  data: [],
  processComplexData: (input) => {
    // 複雑な処理をストア内で実行
    const processed = input.map((item) => {
      // 50行の複雑な処理...
    });
    set({ data: processed });
  },
}));

// 良い例:処理は外部のサービスに分離
const dataService = {
  processData: (input) => {
    // 複雑な処理
    return processedData;
  },
};

const useDataStore = create((set) => ({
  data: [],
  setData: (data) => set({ data }),
}));

3. 不必要な状態の重複

typescript// 悪い例:派生状態を別途保存
const useBadStore = create((set) => ({
  items: [],
  itemCount: 0, // これは派生状態
  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
      itemCount: state.items.length + 1, // 手動で更新
    })),
}));

// 良い例:派生状態はセレクターで取得
const useGoodStore = create((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
    })),
}));

// 使用時
const itemCount = useGoodStore(
  (state) => state.items.length
);

よく発生するエラーとその解決方法も押さえておきましょう。

typescript// エラー例1: TypeError: Cannot read properties of undefined
// 原因:初期状態がundefinedの場合
const useProblematicStore = create(() => ({
  user: undefined, // 問題の原因
}));

// 解決方法:初期値を適切に設定
const useFixedStore = create(() => ({
  user: null, // nullまたは適切な初期値
}));

// エラー例2: Maximum update depth exceeded
// 原因:無限ループが発生
function ProblematicComponent() {
  const setUser = useUserStore((state) => state.setUser);

  useEffect(() => {
    setUser({}); // 毎回新しいオブジェクトを作成
  }, [setUser]); // setUserは毎回新しい関数として認識される

  return <div>...</div>;
}

// 解決方法:useCallbackを使用
function FixedComponent() {
  const setUser = useUserStore((state) => state.setUser);

  const handleSetUser = useCallback(() => {
    setUser({});
  }, [setUser]);

  useEffect(() => {
    handleSetUser();
  }, [handleSetUser]);

  return <div>...</div>;
}

まとめ

UI コンポーネントと Zustand の最適な依存関係を構築することで、以下のような価値を得られます。

技術的な価値

  • 保守性の向上: 責務が明確に分離され、コードの理解と修正が容易になります
  • パフォーマンスの最適化: 必要な部分のみの再レンダリングにより、アプリケーションの応答性が向上します
  • テストの容易さ: ストアとコンポーネントが独立しているため、単体テストが書きやすくなります

開発体験の向上

  • 学習コストの削減: シンプルな API により、チームメンバーが素早く理解できます
  • 型安全性: TypeScript との組み合わせにより、開発時のエラーを早期発見できます
  • デバッグの効率化: 状態の変更が追跡しやすく、問題の特定が容易になります

React 開発において、状態管理は避けて通れない重要な要素です。しかし、適切な設計パターンを理解し、実践することで、複雑さを制御し、美しいコードを書くことができます。

Zustand を使用することで、あなたのアプリケーションは単に「動く」だけでなく、「理解しやすく」「保守しやすく」「拡張しやすい」ものになるでしょう。これは、あなた自身の成長はもちろん、チーム全体の生産性向上にもつながります。

ぜひ、今回ご紹介したパターンを実際のプロジェクトで試してみてください。きっと、React 開発における新しい可能性を発見できるはずです。

関連リンク