<CodeLearn/>
状態管理 レッスン5

状態管理総合演習

ショッピングカートを作って状態管理を実践しよう

演習の概要

この演習では、ショッピングカート機能を持つアプリケーションを Zustandを使って実装します。商品一覧の表示、カートへの追加・削除、合計金額の計算など、 実践的な状態管理を体験しましょう。

  • 商品一覧の表示と検索・フィルタリング
  • カートへの追加・数量変更・削除
  • 合計金額の自動計算
  • カートの永続化(ページリロードしても保持)

Step 1: 型定義とデータの準備

まず、商品とカートアイテムの型を定義し、サンプルデータを用意します。

// types.ts
export interface Product {
  id: string;
  name: string;
  price: number;
  category: "food" | "drink" | "snack";
  image: string;
  description: string;
}

export interface CartItem extends Product {
  quantity: number;
}

// data/products.ts
import { Product } from "../types";

export const products: Product[] = [
  {
    id: "1",
    name: "有機バナナ",
    price: 198,
    category: "food",
    image: "/images/banana.jpg",
    description: "フィリピン産の有機栽培バナナ",
  },
  {
    id: "2",
    name: "緑茶 500ml",
    price: 150,
    category: "drink",
    image: "/images/greentea.jpg",
    description: "静岡県産の茶葉を使用",
  },
  {
    id: "3",
    name: "チョコレート",
    price: 320,
    category: "snack",
    image: "/images/chocolate.jpg",
    description: "カカオ70%のダークチョコレート",
  },
  {
    id: "4",
    name: "鮭おにぎり",
    price: 160,
    category: "food",
    image: "/images/onigiri.jpg",
    description: "北海道産紅鮭使用",
  },
  {
    id: "5",
    name: "コーヒー 350ml",
    price: 180,
    category: "drink",
    image: "/images/coffee.jpg",
    description: "ブラジル産豆のブラックコーヒー",
  },
  {
    id: "6",
    name: "ポテトチップス",
    price: 248,
    category: "snack",
    image: "/images/chips.jpg",
    description: "うすしお味のポテトチップス",
  },
];

Step 2: Zustandストアの実装

カートのストアを作成します。追加・削除・数量変更と、合計金額の計算ロジックを実装します。persistミドルウェアで localStorageに自動保存します。

// store/useCartStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Product, CartItem } from "../types";

interface CartState {
  items: CartItem[];
  addItem: (product: Product) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  getTotalItems: () => number;
  getTotalPrice: () => number;
}

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

      addItem: (product) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === product.id);
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === product.id
                  ? { ...i, quantity: i.quantity + 1 }
                  : i
              ),
            };
          }
          return {
            items: [...state.items, { ...product, quantity: 1 }],
          };
        }),

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

      updateQuantity: (productId, quantity) =>
        set((state) => {
          if (quantity <= 0) {
            return { items: state.items.filter((i) => i.id !== productId) };
          }
          return {
            items: state.items.map((i) =>
              i.id === productId ? { ...i, quantity } : i
            ),
          };
        }),

      clearCart: () => set({ items: [] }),

      getTotalItems: () => {
        return get().items.reduce((sum, item) => sum + item.quantity, 0);
      },

      getTotalPrice: () => {
        return get().items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        );
      },
    }),
    { name: "cart-storage" }
  )
);

export default useCartStore;

Step 3: 商品一覧コンポーネント

商品を一覧表示し、カテゴリでフィルタリングできるコンポーネントを作ります。

// components/ProductList.tsx
import { useState } from "react";
import { products } from "../data/products";
import { Product } from "../types";
import useCartStore from "../store/useCartStore";

type Category = "all" | "food" | "drink" | "snack";

const categoryLabels: Record<Category, string> = {
  all: "すべて",
  food: "食品",
  drink: "飲料",
  snack: "お菓子",
};

function ProductList() {
  const [filter, setFilter] = useState<Category>("all");
  const addItem = useCartStore((state) => state.addItem);

  const filteredProducts =
    filter === "all"
      ? products
      : products.filter((p) => p.category === filter);

  return (
    <div>
      <h2>商品一覧</h2>

      {/* カテゴリフィルター */}
      <div style={{ display: "flex", gap: "8px", marginBottom: "16px" }}>
        {(Object.keys(categoryLabels) as Category[]).map((cat) => (
          <button
            key={cat}
            onClick={() => setFilter(cat)}
            style={{
              padding: "8px 16px",
              borderRadius: "20px",
              border: "none",
              background: filter === cat ? "#7c3aed" : "#374151",
              color: "white",
              cursor: "pointer",
            }}
          >
            {categoryLabels[cat]}
          </button>
        ))}
      </div>

      {/* 商品カード */}
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "16px" }}>
        {filteredProducts.map((product) => (
          <ProductCard key={product.id} product={product} onAdd={addItem} />
        ))}
      </div>
    </div>
  );
}

function ProductCard({
  product,
  onAdd,
}: {
  product: Product;
  onAdd: (p: Product) => void;
}) {
  return (
    <div style={{
      border: "1px solid #374151",
      borderRadius: "12px",
      padding: "16px",
      background: "#111827",
    }}>
      <h3>{product.name}</h3>
      <p style={{ color: "#9ca3af" }}>{product.description}</p>
      <p style={{ fontSize: "18px", fontWeight: "bold", color: "#a78bfa" }}>
        ¥{product.price.toLocaleString()}
      </p>
      <button
        onClick={() => onAdd(product)}
        style={{
          width: "100%",
          padding: "8px",
          borderRadius: "8px",
          border: "none",
          background: "#7c3aed",
          color: "white",
          cursor: "pointer",
        }}
      >
        カートに追加
      </button>
    </div>
  );
}

Step 4: カートコンポーネント

カートの中身を表示し、数量変更や削除、合計金額の表示を行うコンポーネントです。

// components/Cart.tsx
import useCartStore from "../store/useCartStore";

function Cart() {
  const items = useCartStore((state) => state.items);
  const removeItem = useCartStore((state) => state.removeItem);
  const updateQuantity = useCartStore((state) => state.updateQuantity);
  const clearCart = useCartStore((state) => state.clearCart);
  const getTotalItems = useCartStore((state) => state.getTotalItems);
  const getTotalPrice = useCartStore((state) => state.getTotalPrice);

  if (items.length === 0) {
    return (
      <div style={{ textAlign: "center", padding: "32px", color: "#9ca3af" }}>
        <p>カートは空です</p>
      </div>
    );
  }

  return (
    <div>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
        <h2>カート ({getTotalItems()} 点)</h2>
        <button
          onClick={clearCart}
          style={{ color: "#ef4444", background: "none", border: "none", cursor: "pointer" }}
        >
          カートを空にする
        </button>
      </div>

      {/* カートアイテム一覧 */}
      {items.map((item) => (
        <div
          key={item.id}
          style={{
            display: "flex",
            justifyContent: "space-between",
            alignItems: "center",
            padding: "12px 0",
            borderBottom: "1px solid #374151",
          }}
        >
          <div>
            <p style={{ fontWeight: "bold" }}>{item.name}</p>
            <p style={{ color: "#a78bfa" }}>
              ¥{item.price.toLocaleString()} x {item.quantity}
            </p>
          </div>

          <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
            <button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
              -
            </button>
            <span>{item.quantity}</span>
            <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
              +
            </button>
            <button
              onClick={() => removeItem(item.id)}
              style={{ color: "#ef4444", marginLeft: "8px" }}
            >
              削除
            </button>
          </div>
        </div>
      ))}

      {/* 合計 */}
      <div style={{
        marginTop: "16px",
        padding: "16px",
        borderRadius: "12px",
        background: "#1f2937",
      }}>
        <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
          <span>小計</span>
          <span>¥{getTotalPrice().toLocaleString()}</span>
        </div>
        <div style={{ display: "flex", justifyContent: "space-between", marginBottom: "8px" }}>
          <span>消費税(10%)</span>
          <span>¥{Math.floor(getTotalPrice() * 0.1).toLocaleString()}</span>
        </div>
        <div style={{
          display: "flex",
          justifyContent: "space-between",
          fontWeight: "bold",
          fontSize: "18px",
          borderTop: "1px solid #374151",
          paddingTop: "8px",
        }}>
          <span>合計</span>
          <span style={{ color: "#a78bfa" }}>
            ¥{Math.floor(getTotalPrice() * 1.1).toLocaleString()}
          </span>
        </div>
      </div>
    </div>
  );
}

Step 5: アプリの統合

商品一覧とカートを統合して、完成したアプリケーションを組み立てます。 ヘッダーにはカートのアイテム数を表示するバッジを付けます。

// App.tsx
import { useState } from "react";
import ProductList from "./components/ProductList";
import Cart from "./components/Cart";
import useCartStore from "./store/useCartStore";

function App() {
  const [showCart, setShowCart] = useState(false);
  const totalItems = useCartStore((state) => state.getTotalItems);

  return (
    <div style={{ maxWidth: "1024px", margin: "0 auto", padding: "16px" }}>
      {/* ヘッダー */}
      <header style={{
        display: "flex",
        justifyContent: "space-between",
        alignItems: "center",
        padding: "16px 0",
        borderBottom: "1px solid #374151",
        marginBottom: "24px",
      }}>
        <h1 style={{ fontSize: "24px", fontWeight: "bold" }}>
          ショッピングアプリ
        </h1>
        <button
          onClick={() => setShowCart(!showCart)}
          style={{
            position: "relative",
            padding: "8px 16px",
            borderRadius: "8px",
            border: "1px solid #374151",
            background: showCart ? "#7c3aed" : "transparent",
            color: "white",
            cursor: "pointer",
          }}
        >
          カート
          {totalItems() > 0 && (
            <span style={{
              position: "absolute",
              top: "-8px",
              right: "-8px",
              background: "#ef4444",
              color: "white",
              borderRadius: "50%",
              width: "20px",
              height: "20px",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              fontSize: "12px",
            }}>
              {totalItems()}
            </span>
          )}
        </button>
      </header>

      {/* メインコンテンツ */}
      <div style={{
        display: "grid",
        gridTemplateColumns: showCart ? "1fr 400px" : "1fr",
        gap: "24px",
      }}>
        <ProductList />
        {showCart && (
          <div style={{
            padding: "16px",
            borderRadius: "12px",
            border: "1px solid #374151",
            background: "#111827",
            height: "fit-content",
            position: "sticky",
            top: "16px",
          }}>
            <Cart />
          </div>
        )}
      </div>
    </div>
  );
}

export default App;

発展課題

基本の実装ができたら、以下の機能を追加してみましょう。

// 発展課題1: お気に入り機能を追加
// - 商品のお気に入り登録・解除
// - お気に入り一覧の表示
// - 別のストア(useFavoritesStore)で管理

// 発展課題2: 検索機能の実装
// - 商品名でのリアルタイム検索
// - 検索キーワードのハイライト
// - デバウンス処理の追加

// 発展課題3: 注文履歴
// - カートの確定(チェックアウト)
// - 注文履歴の保存と一覧表示
// - useOrderHistoryStore で別管理

// 発展課題4: Context版で書き直し
// - Zustandの代わりにuseContextで同じ機能を実装
// - コード量や複雑さを比較してみよう

// 発展課題5: Redux Toolkit版で書き直し
// - createSliceとconfigureStoreで同じ機能を実装
// - createAsyncThunkでAPI連携を追加

まとめ

  • 型定義から始めてデータ構造を明確にすることで、安全な実装ができる
  • Zustandのcreatepersistで、簡潔かつ永続化されたストアを作成できる
  • セレクタを使って必要な値だけを取得し、パフォーマンスを最適化する
  • コンポーネントを分割して関心事を分離すると、保守性の高いコードになる
  • 状態管理ライブラリを使い比べることで、それぞれの特徴が実感できる