Next.jsでショップを作る #3 カート

読了 9分

前回はカタログの2つのページを完成させました。そこまでのコードはすべてServer Componentでした。今回の記事で初めてClient Componentが登場します。ユーザーが商品をカートに入れ、数量を変え、リロードしてもその状態が生き残る — カートを作る番だからです。

カートは、未ログインのECサイトの標準がそうであるようにローカルだけで維持されます。サーバーは決済の直前までユーザーのカートを知りません。localStorageがその永続レイヤーを担います。

どこまでがクライアントか #

まず境界を明確に引きます。

領域場所理由
商品データServerJSONで静的。サーバーで読むのが自然で速い
カタログ画面Serverユーザーインタラクションなし。SEOフレンドリー
カートの状態Client (Context)ユーザーごとに異なり頻繁に変わる。リロード時の維持が必要
カート画面Client状態を直接レンダリング
「カートに入れる」ボタンClientクリックがカートを変更
ヘッダーの数量バッジClientカートの変更に即座に反応

核心となる原則は「基本はサーバー、インタラクションが必要な小さな単位だけクライアント」です。ヘッダー自体はServer Componentのままにして、その中の数量バッジだけをClient Componentとして切り出す、という形です。

CartProviderの設計 #

カートが持つデータの形を先に決めます。

カートアイテムの形
{
  slug: 'minimal-tee-black',
  name: 'ミニマルTシャツ (ブラック)',
  price: 24000,
  image: '/images/products/minimal-tee-black.jpg',
  quantity: 2,
}

商品全体を丸ごと入れる必要はありません。チェックアウトに必要な最小限だけをカートに置きます。価格が途中で変わる可能性を無視すれば(学習段階では無視します)、カートに価格を持たせておくのが一番シンプルです。

src/app/components/CartProvider.js

src/app/components/CartProvider.js
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

const CartContext = createContext(null);
const STORAGE_KEY = 'cart-v1';

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) setItems(JSON.parse(raw));
    } catch {
      // 壊れたJSONは無視して空のカートで開始
    }
    setHydrated(true);
  }, []);

  useEffect(() => {
    if (!hydrated) return;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
  }, [items, hydrated]);

  function addItem(product, quantity = 1) {
    setItems((prev) => {
      const found = prev.find((it) => it.slug === product.slug);
      if (found) {
        return prev.map((it) =>
          it.slug === product.slug ? { ...it, quantity: it.quantity + quantity } : it,
        );
      }
      return [
        ...prev,
        {
          slug: product.slug,
          name: product.name,
          price: product.price,
          image: product.image,
          quantity,
        },
      ];
    });
  }

  function updateQuantity(slug, quantity) {
    if (quantity <= 0) return removeItem(slug);
    setItems((prev) => prev.map((it) => (it.slug === slug ? { ...it, quantity } : it)));
  }

  function removeItem(slug) {
    setItems((prev) => prev.filter((it) => it.slug !== slug));
  }

  function clear() {
    setItems([]);
  }

  const totalQuantity = items.reduce((sum, it) => sum + it.quantity, 0);
  const totalPrice = items.reduce((sum, it) => sum + it.price * it.quantity, 0);

  const value = {
    items,
    hydrated,
    totalQuantity,
    totalPrice,
    addItem,
    updateQuantity,
    removeItem,
    clear,
  };

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

export function useCart() {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCartはCartProviderの内部でのみ呼び出せます');
  return ctx;
}

このコンポーネントの核心は2つのuseEffectです。

  • 最初のeffect (マウント時に1回): localStorageから保存されたカートをロード。終わったらhydratedをtrueに。
  • 2つ目のeffect (itemsが変わるたび): カートをlocalStorageに保存し直す。ただしhydratedがtrueのときだけ。

hydratedガードを置く理由は、マウント直後に最初のeffectが実行される前に、2つ目のeffectがitems: []をlocalStorageに上書きしてしまう事故を防ぐためです。2つのeffectがどちらもマウント時に実行されるとしても、hydratedフラグを置けば意図がコードにより明確に残ります。

useCartがProviderの外部で呼び出されるとnullコンテキストが返りますが、そのままにするとctx.itemsのような呼び出しで曖昧なエラーが出ます。明示的にthrowするほうがデバッグが楽になります。

Hydration安全パターン #

サーバーレンダリングの時点ではlocalStorageが存在しないので、カートは[]です。クライアントがマウントされるとlocalStorageからカートをロードして画面が変わります。その差がユーザーにちらつきとして見えるのが気になるかもしれません。

対処法は2つです。

  1. 数量バッジのような小さな要素は、hydratedがtrueになってからだけ描画する — 最初の描画ではバッジが一瞬見えず、すぐに正確な数字が現れます。
  2. /cartページのようにカートが主役の画面は、hydrationの間「読み込み中」のようなplaceholderを表示します。

後者のほうがユーザーの体感としてなめらかです。実際のコードは下で適用します。

RootLayoutにCartProviderを挟む #

カートの状態はほぼすべてのページから参照するので、RootLayoutの中にProviderを置きます。

src/app/layout.js (変更部分)。

src/app/layout.js
import Link from 'next/link';
import { CartProvider } from './components/CartProvider';
import CartBadge from './components/CartBadge';
import './globals.css';

export const metadata = {
  title: { default: 'マイショップ', template: '%s | マイショップ' },
  description: '学習用ショップビルド',
};

export default function RootLayout({ children }) {
  return (
    <html lang="ja">
      <body>
        <CartProvider>
          <header className="border-b">
            <div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
              <Link href="/" className="font-bold">マイショップ</Link>
              <nav className="flex items-center gap-4 text-sm">
                <Link href="/products">商品</Link>
                <Link href="/cart" className="relative">
                  カート
                  <CartBadge />
                </Link>
              </nav>
            </div>
          </header>
          {children}
        </CartProvider>
      </body>
    </html>
  );
}

'use client'が付いたCartProviderがRootLayoutの中に入っても、その外側(ヘッダーのマークアップなど)は依然としてサーバーでレンダリングされます。CartProviderはクライアント境界の始点であるだけで、子コンポーネントが自動的にクライアントになるわけではありません。子がServer Componentならそのままサーバーで描画されてpropsとして下りてきます。ヘッダーの静的マークアップがその例です。

CartBadge — 数量バッジ #

src/app/components/CartBadge.js

src/app/components/CartBadge.js
'use client';

import { useCart } from './CartProvider';

export default function CartBadge() {
  const { totalQuantity, hydrated } = useCart();

  if (!hydrated || totalQuantity === 0) return null;

  return (
    <span className="absolute -right-3 -top-2 rounded-full bg-red-500 px-1.5 py-0.5 text-xs text-white">
      {totalQuantity}
    </span>
  );
}

hydratedがfalseならバッジを描画しません。サーバーレンダリングの結果と最初のクライアントレンダリングの結果がどちらも「バッジなし」で一致するので、hydration mismatchが発生しません。

AddToCartButton — 「カートに入れる」ボタン #

詳細ページの空のボタンを動かす番です。ページ自体はServer Componentのままにして、ボタンだけをClient Componentとして切り出します。

src/app/components/AddToCartButton.js

src/app/components/AddToCartButton.js
'use client';

import { useState } from 'react';
import { useCart } from './CartProvider';

export default function AddToCartButton({ product }) {
  const { addItem } = useCart();
  const [justAdded, setJustAdded] = useState(false);

  function handleClick() {
    addItem(product, 1);
    setJustAdded(true);
    setTimeout(() => setJustAdded(false), 1500);
  }

  return (
    <button
      type="button"
      onClick={handleClick}
      className="rounded-md bg-black px-4 py-3 text-white hover:bg-gray-800"
    >
      {justAdded ? 'カートに追加しました' : 'カートに入れる'}
    </button>
  );
}

1.5秒間「カートに追加しました」というフィードバックを見せる小さなディテールを入れました。カートページに強制的に遷移させないのがユーザーフローとしてなめらかです — 他の商品を引き続き見て回れるように。

詳細ページ(src/app/products/[slug]/page.js)で無効化ボタンがあった部分を次のように変えます。

src/app/products/[slug]/page.js — 変更部分
import AddToCartButton from '../../components/AddToCartButton';

// ...

{isOutOfStock ? (
  <button type="button" disabled className="rounded-md bg-gray-300 px-4 py-3 text-white">
    在庫切れ
  </button>
) : (
  <AddToCartButton
    product={{
      slug: product.slug,
      name: product.name,
      price: product.price,
      image: product.image,
    }}
  />
)}

product全体ではなく、カートに必要なフィールドだけを選んで渡します。Server → Client境界を通過するpropsはシリアライズ可能でなければならないので、不要なフィールドまで渡さない習慣が良いです。

/cart — カートページ #

src/app/cart/page.js
'use client';

import Link from 'next/link';
import Image from 'next/image';
import { useCart } from '../components/CartProvider';
import { formatPrice } from '../lib/format';

export default function CartPage() {
  const { items, hydrated, totalPrice, updateQuantity, removeItem } = useCart();

  if (!hydrated) {
    return (
      <main className="mx-auto max-w-3xl px-4 py-8">
        <h1 className="text-2xl font-bold">カート</h1>
        <p className="mt-4 text-gray-500">読み込み中</p>
      </main>
    );
  }

  if (items.length === 0) {
    return (
      <main className="mx-auto max-w-3xl px-4 py-8">
        <h1 className="text-2xl font-bold">カート</h1>
        <p className="mt-4 text-gray-500">カートは空です</p>
        <Link
          href="/products"
          className="mt-4 inline-block rounded-md border px-4 py-2 text-sm"
        >
          商品を見て回る
        </Link>
      </main>
    );
  }

  return (
    <main className="mx-auto max-w-3xl px-4 py-8">
      <h1 className="text-2xl font-bold">カート</h1>

      <ul className="mt-6 divide-y border-y">
        {items.map((it) => (
          <li key={it.slug} className="flex items-center gap-4 py-4">
            <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded bg-gray-100">
              <Image src={it.image} alt={it.name} fill sizes="80px" className="object-cover" />
            </div>
            <div className="flex-1">
              <Link href={`/products/${it.slug}`} className="font-medium hover:underline">
                {it.name}
              </Link>
              <div className="mt-1 text-sm text-gray-600">{formatPrice(it.price)}</div>
            </div>
            <div className="flex items-center gap-2">
              <button
                type="button"
                onClick={() => updateQuantity(it.slug, it.quantity - 1)}
                className="h-8 w-8 rounded border"
                aria-label="数量を減らす"
              >
                
              </button>
              <span className="w-8 text-center">{it.quantity}</span>
              <button
                type="button"
                onClick={() => updateQuantity(it.slug, it.quantity + 1)}
                className="h-8 w-8 rounded border"
                aria-label="数量を増やす"
              >
                +
              </button>
            </div>
            <div className="w-24 text-right font-semibold">
              {formatPrice(it.price * it.quantity)}
            </div>
            <button
              type="button"
              onClick={() => removeItem(it.slug)}
              className="text-sm text-gray-500 hover:text-red-500"
            >
              削除
            </button>
          </li>
        ))}
      </ul>

      <div className="mt-6 flex items-center justify-between">
        <div className="text-lg">合計</div>
        <div className="text-2xl font-bold">{formatPrice(totalPrice)}</div>
      </div>

      <Link
        href="/checkout"
        className="mt-6 inline-block w-full rounded-md bg-black px-4 py-3 text-center text-white"
      >
        決済へ進む
      </Link>
    </main>
  );
}

いくつかのディテール。

  • hydrationの間は「読み込み中」hydratedがfalseの最初の描画で空のカートに見えるちらつきを防ぎます。
  • 数量が0に減ったら自動削除updateQuantity関数の中でquantity <= 0ならremoveItemに委譲。ユーザーが「−」を最後まで押したときに自然な動作です。
  • aria-label — 「+」/「−」ボタンは視覚的には明確ですが、スクリーンリーダーには曖昧です。
  • 決済へ進むリンク — 次の記事の入口です。

動作確認 #

  • http://localhost:3000/products/minimal-tee-black → 「カートに入れる」をクリック → ヘッダーに赤いバッジ1
  • 同じ商品をあと2回入れる → バッジ3/cartで数量3にまとまる
  • 別の商品を追加 → /cartに2行
  • リロード → カートはそのまま維持
  • 「+」/「−」で数量変更 → 合計が即座に更新、localStorageも更新
  • 数量を0まで減らすか「削除」をクリック → アイテムが削除される
  • 最後のアイテムを削除 → 空カートの案内 + 商品を見て回るリンク

ブラウザDevToolsのApplicationタブ → Local Storageでcart-v1キーを開くと、カートの状態がJSONとして保存されているのが見えます。

おわりに #

今回の記事では、クライアントの状態と永続化を初めて扱いました。

  • Server / Client境界を明確に線引き (Providerの一点だけでクライアントに切り替え)
  • CartProviderでContext APIとlocalStorage永続化をひとまとめに
  • hydration安全パターンを適用 (hydratedフラグ)
  • 数量バッジ、カートに入れるボタン、カートページがすべて同じ状態を共有
  • リロードしてもカートはそのまま維持

ここまで作った画面はすべて読み取り中心でした。カートもクライアントの状態であるだけで、サーバーはまだカートの存在を知りません。次の記事で初めてサーバーに何かを作って送ります — 注文です。「Next.jsでショップを作る #4 チェックアウトと決済シミュレーション」では、注文フォームを作り、Server Actionsで注文を生成し、偽の決済ゲートウェイを作って成功/失敗の分岐を扱うところまで進みます。

X