😎

Next.js, Recoil, nookiesを使用して、カート機能を実装しよう!!

2022/08/01に公開

Next.jsとRecoil, nookiesを使用してショッピングサイトにあるカート機能を実装していきます。

今回の作るのも

こんな感じのショッピングサイトのカート機能を実装していきます。
github でソースコードが確認できます。
https://github.com/abeshi03/payment-form

実装イメージとしては下記です。

・商品の取得はjson-serverでモックで行う
・Recoilでカートの商品情報をglobalにもつ
・nookiesを使用してクッキーにカートの値を保存し、リロードやページ離脱があった際も、globalなカートの値を保てるようにする
・カート機能がメインなので、デザインは簡略化する

使用技術は下記です。

・Next.js
・TypeScript
・Recoil
・nookies
・json-server

では早速やっていきましょう。

json-serverで商品情報を登録する

Next.jsのプロジェクトを立ち上げたていで今回は進んでいきます。
今回はカートの実装がメインなので、json-serverでモックの商品を作成します。

npm i json-server
// mock/db.json

{
  "products": [
    {
      "id": 1,
      "name": "商品1",
      "price": 3000
    },
    {
      "id": 2,
      "name": "商品2",
      "price": 2000
    },
    {
      "id": 3,
      "name": "商品3",
      "price": 5000
    },
    {
      "id": 4,
      "name": "商品4",
      "price": 400
    },
    {
      "id": 5,
      "name": "商品5",
      "price": 100
    },
    {
      "id": 6,
      "name": "商品6",
      "price": 8000
    },
    {
      "id": 7,
      "name": "商品7",
      "price": 2500
    }
  ]
}

モックの商品情報を追加していきます。

型定義も追加しておきましょう。

// types/Products.ts
export type Product = {
  id: number;
  name: string;
  price: number;
  quantity: number;
};

型定義にはカートの際に数量を保存する必要があるので、quantityを入れています。

カートの機能を実装しよう

カートの機能から実装していきます。
カートの情報はどのページからも参照したいのでglobalstateとして扱っていきます。

今回はRecoilを使用します。
installしていきましょう。

npm i Recoil

Recoilの設定

// stores/cart.tsx
import { atom, RecoilState, selector, useRecoilState } from "recoil";

import { Product } from "../types/Product";

import { Cart } from "../components/molecules/Cart/Cart";

export type Cart = {
  products: Product[];
};

const initialState: Cart = {
  products: []
};

export const cartState: RecoilState<Cart> = atom({
  key: "cartState",
  default: initialState
});

これでRecoilの設定は完了です。
簡単に説明すると、まずカートの型定義をしています。

initialStateはグローバルstateの初期値を表します。
初めは商品は入っていないはずなので空配列にしましょう。

export const cartState: RecoilState<Cart> = atom({
  key: "cartState",
  default: initialState
});

この部分で、Recoilのatomを宣言し、globalstateとして扱うようにします。

// _app.tsx
function MyApp({ Component, pageProps }: AppProps) {
  return (
    <RecoilRoot>
      <Header />
      <Component {...pageProps} />;
    </RecoilRoot>
  );
}

RecoilRootでラップし、globalに扱えるようにしていきます

ここから以下3点の実装をしていきます。

・カートへの商品追加処理
・カートへの商品削除処理
・合計価格の計算処理

合計価格の計算の実装

先に合計金額の計算から行なっていきます。
ここではRecoilのSelectorsという機能を使っていきます。

https://recoiljs.org/docs/basic-tutorial/selectors/

合計金額はカートの中の
商品金額 * 数量を元の合計価格にどんどん加算していく形で算出できますね。

// stores/cart.tsx
export const totalPriceSelector = selector({
  key: "totalPriceSelector",
  get: ({ get }) => {
    const cart = get(cartState);
    return cart.products.reduce((sum, product) => {
      return sum + product.price * product.quantity;
    }, 0);
  }
});

selectorはatomの値を元に新たなgetterを作成することが可能です。
getでカートを取得しています。

reduceを使用しこのカートの中のpriceとquantityで合計価格を算出しています。

カートへの追加、削除ロジックを実装していく

カートへの追加と削除処理ですが、こちらは様々なページで使用できた法が良いのでカスタムフックスとして
切り出していきます。
こうすることで、viewとロジックを分離できるメリットもあります。

// stores/cart.ts

export const useCart = () => {
  const [cart, setCart] = useRecoilState(cartState);

  /* --- カートへ商品追加 ----------------------------------------------------------------------------------------------- */
  const addCart = (product: Product): void => {
    const selectItem = cart.products.find((_product) => _product.id === product.id);

    // カートに商品が入っていない場合
    if (!selectItem) {
      product.quantity = 1;
      setCart({
        products: [...cart.products, product]
      });
    } else {
      // カートに商品が入っている場合
      setCart((prevCart) => {
        return {
          products: prevCart.products.map((_product) =>
            _product.id === selectItem.id ? { ..._product, quantity: _product.quantity + 1 } : _product
          )
        };
      });
    }
  };

  const removeCart = (product: Product) => {
    const selectItem = cart.products.find((_product) => _product.id === product.id);

    if (!selectItem) {
      console.warn("selectItemがundefinedのはずがない, バグの可能性あり");
      return;
    }

    // カートから商品を-1する
    if (selectItem.quantity > 1) {
      setCart((prevCart) => {
        return {
          products: prevCart.products.map((_product) =>
            _product.id === selectItem.id ? { ..._product, quantity: _product.quantity - 1 } : _product
          )
        };
      });
    } else {
      // カートから商品を削除する
      const products = [...cart.products];
      const index = products.findIndex((product) => product.id === selectItem.id);
      if (index === -1) return;
      products.splice(index, 1);

      setCart({
        products
      });
    }
  };

  return { addCart, removeCart };
};

まずはカートへの商品追加処理です。

const [cart, setCart] = useRecoilState(cartState);

この部分でカートの値をまず取得しています。
これはRecoilの書き方でuseStateとほぼ同義の意味を持ちます。

カートへ追加する際、まずはその商品があるかを確認する必要があります。

・商品があれば、商品は追加せず、数量を1追加
・商品がなければ、商品を追加し数量1とする

です。

商品が存在するかはfind分を使用して判断していきます。

const selectItem = cart.products.find((_product) => _product.id === product.id);
// カートに商品が入っていない場合
if (!selectItem) {
  product.quantity = 1;
  setCart({
    products: [...cart.products, product]
  });
} else {
  // カートに商品が入っている場合
  setCart((prevCart) => {
    return {
      products: prevCart.products.map((_product) =>
        _product.id === selectItem.id ? { ..._product, quantity: _product.quantity + 1 } : _product
      )
    };
  });
}

商品が入っていない場合、新たに商品を追加する必要がある為、setCartでスプレット公文を利用し、商品を追加します。

elseは商品が入っていることになるので、数量1を1増やします。

こちらは少し複雑ですが

setCart((prevCart) => {
  return {
    products: prevCart.products.map((_product) =>
      _product.id === selectItem.id ? { ..._product, quantity: _product.quantity + 1 } : _product
    )
  };
});

setCartの引数にprevCartをとり、元のカートの値を取得します。
カートは配列で定義してあるので、一つの商品を+1するには配列をmapで展開してあげる必要があります。
三項演算子で、カートの商品と新たな商品のidが一致するか再度確認し、スプレット公文でquantityの値だけ元の値から+1してあげます。

これでカートの追加実装は完了です。

次に商品の削除処理です。

この場合

・カートに商品が2以上あれば、数量を-1
・商品の数量が1であれば、商品を削除

といったロジックになります。

数量を-1する場合は、カートの追加処理とほとんど同じでquantityを-1するだけです。

setCart((prevCart) => {
  return {
    products: prevCart.products.map((_product) =>
      _product.id === selectItem.id ? { ..._product, quantity: _product.quantity - 1 } : _product
    )
};

カートの商品を削除する場合は

const products = [...cart.products];
const index = products.findIndex((product) => product.id === selectItem.id);
if (index === -1) return;
products.splice(index, 1);

setCart({
  products
});

まず商品をコピーします。
コピーした商品の中から、該当商品のindexをfindIndexで探します。

この場合、findIndexで必ずあるはずですが、findIndexは-1を返す場合がある為、
一応エラーハンドリングも書いています。

あとはspliceを使って該当のindexを一つ削除するだけです。

こちらをカスタムフックスとしてreturn します

return { addCart, removeCart };

こちらでカートロジック処理は完了なので、あとはこれをviewに反映させ、cookiesに保存していきます。

Productカードのコンポーネントを実装する

これです。

// components/ProductCard.tsx
import { FC, memo } from "react";

import { useCart } from "../../../stores/cart";

import styles from "./ProductCard.module.scss";

import { Product } from "../../../types/Product";

type Props = {
  product: Product;
};

export const ProductCard: FC<Props> = memo((props) => {
  const { product } = props;

  const { addCart } = useCart();

  return (
    <div className={styles.productCard}>
      <div
        className={styles.image}
        role="img"
        style={{ backgroundImage: `url(https://dummyimage.com/100x100/03488d/fff})` }}
      ></div>
      <h2 className={styles.name}>{product.name}</h2>
      <p className={styles.price}>{product.price}円</p>
      <button onClick={() => addCart(product)} className={styles.button}>
        カードに入れる
      </button>
    </div>
  );
});

ProductCard.displayName = "ProductCard";

propsでProductの値を親からもらうようにします。
cssはこちら
https://github.com/abeshi03/payment-form/blob/main/components/molecules/ProductCard/ProductCard.module.scss

カートへ追加するのボタンをクリックする際に先ほど作成したaddCartをカスタムフックスで呼び出し、使用します。
viewとロジックが分かれてみやすいかと思います!!

商品一覧ページを実装する

// pages/index.tsx

import { NextPage } from "next";
import { useEffect, useState } from "react";

import styles from "./Home.module.scss";

import { Product } from "../types/Product";

import { ProductCard } from "../components/molecules/ProductCard/ProductCard";

const Home: NextPage = () => {
  const [products, setProducts] = useState<Product[]>([]);

  const getProducts = async () => {
    const res = await fetch("http://localhost:3004/products");
    const products = await res.json();
    setProducts(products);
  };

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

  return (
    <div className={styles.home}>
      <h1 className={styles.heading}>商品一覧</h1>

      <div className={styles.cardsFlow}>
        {products.map((product) => (
          <ProductCard product={product} key={product.id} />
        ))}
      </div>
    </div>
  );
};

export default Home;

始めに作成したjson-serverから商品のモックデータをuseEffectで取得しています。
クライアント側で叩いているのでローディング処理が必要ですが、今回は割愛します。

先ほど作ったProductCardコンポーネントに取得した商品の配列を展開し渡していきます。

これで商品一覧ページは完成です。

ヘッダーを作成していく

次はカートページへの導線を追加していきましょう。
クリックするたびに、カートの数が増えるよくあるイメージです。

// components/Header.tsx
import { FC, memo } from "react";
import { useRecoilValue } from "recoil";
import Link from "next/link";

import styles from "./Header.module.scss";

import { cartState } from "../../../stores/cart";

export const Header: FC = memo(() => {
  const cart = useRecoilValue(cartState);

  return (
    <>
      <header className={styles.header}>
        <div className={styles.container}>
          <Link className={styles.cart} href={"/payments"}>
            <a>
              <span>カート</span>
              {cart.products.length >= 1 && <span className={styles.count}>{cart.products.length}</span>}
            </a>
          </Link>
        </div>
      </header>
    </>
  );
});

Header.displayName = "Header";

cssはこちら
https://github.com/abeshi03/payment-form/blob/main/components/layouts/Header/Header.module.scss

こちらの処理は非常に簡単で

const cart = useRecoilValue(cartState);
{cart.products.length >= 1 && <span className={styles.count}>{cart.products.length}</span>}

カートに商品が追加されたら数字を動的に変えたいので、カートの商品が1以上の場合、カートの商品数を表示するようにします。
こうすることで、数量が増えても数は変わらず、商品数に連動する形になります。

カートページを実装する

今度は追加した商品を決済できるようなページを作成します。

// pages/payments/index.tsx

import { NextPage } from "next";
import { useCallback } from "react";
import { useRecoilValue } from "recoil";

import styles from "./payments.module.scss";

import { cartState, totalPriceSelector } from "../../stores/cart";

import { Cart } from "../../components/molecules/Cart/Cart";


const PaymentsPage: NextPage = () => {
  const cart = useRecoilValue(cartState);
  const totalPrice = useRecoilValue(totalPriceSelector);

  return (
    <div className={styles.paymentsPage}>
      <h2 className={styles.heading}>カート</h2>

      <div className={styles.cartItemsFlow}>
        {cart.products.length === 0 && <p>カートにアイテムが入っていません</p>}
        {cart.products.map((product) => (
          <Cart product={product} key={product.id} />
        ))}
        <div className={styles.totalPrice}>{totalPrice}円</div>
      </div>
    </div>
  );
};

export default PaymentsPage;
const cart = useRecoilValue(cartState);
const totalPrice = useRecoilValue(totalPriceSelector);

でcartとtotalPriceの値を取得します。
totalPriceはRecoilのselectorの機能を使用することで、こんなに簡単に取得できます。

これをuseContextとかで行なったら、いちいちカートに追加する際に計算ロジックを含む必要があるので
非常に便利な機能ですね。

カートに商品がある場合、カートの商品をmapで展開し、Cartコンポーネントに渡していきます。
Cartコンポーネトはこんな感じです。

// components/Cart.tsx

import React, { memo, FC } from "react";

import styles from "./Cart.module.scss";

import { Product } from "../../../types/Product";

import { useCart } from "../../../stores/cart";

type Props = {
  product: Product;
};

export const Cart: FC<Props> = memo((props) => {
  const { product } = props;

  const { addCart, removeCart } = useCart();

  return (
    <div className={styles.cartItem}>
      <div
        className={styles.image}
        role="img"
        style={{ backgroundImage: `url(https://dummyimage.com/100x100/03488d/fff})` }}
      ></div>
      <h2 className={styles.name}>{product.name}</h2>
      <p className={styles.price}>数量: {product.quantity}</p>
      <div className={styles.countControl}>
        <button className={styles.increment} tabIndex={0} onClick={() => addCart(product)}>
          +
        </button>
        <button className={styles.decrement} tabIndex={0} onClick={() => removeCart(product)}>
          -
        </button>
      </div>
      <p className={styles.price}>{product.price * product.quantity}円</p>
    </div>
  );
});

Cart.displayName = "Cart";

こちらも始めにカスタムフックスを作成したおかげで、

const { addCart, removeCart } = useCart();

こちらで取得してクリックイベントに呼ぶだけで実装できます!!

最後にcookiesを利用してRecoilを永続化する

今の状態ではリロードするとRecoilの状態が失われてしまします。
こちら非常にまずいです。

対処法として以下があります。

・localStorageに保存する
・Cookiesに保存する
・バックエンドでcartの情報を保持する

まずlocalStorageですが、Next.jsではserver側のことも考慮しなければいけないので、こちらはあまりうまく動きませんでした。
大規模なECサイト等では、バックエンド側で保持する手法が取られますが、今回はフロントの内容なので、
Cookiesで保持していくことにしましょう。

※Recoilの永続化の注意点

recoil永続化で調べるとrecoil-persistというライブラリーがよく記事で出てきますが、
こちらReact v18では動作しません。(v17では動作するようです)

積極的にメンテナンスもされていないようなので、使うのはあまりおすすめできません。

jsでCookiesを扱うにはjs-cookiesが有名ですが、こちらも使用してみましたが、Next.jsではserver側も考慮することから
少し相性が悪いように感じました。

nookiesを使用する

nookiesはNext.jsでクライアント側でもserver側でも簡単にcookiesを扱えるライブラリーです。
https://github.com/maticzav/nookies

カートの情報はどのページでも失われて欲しくないので、_app.tsxで記述していきます。

// pages_app.tsx

import type { AppProps } from "next/app";
import { RecoilRoot, useRecoilValue, useSetRecoilState } from "recoil";
import { useEffect } from "react";
import { parseCookies, setCookie } from "nookies";

import { Cart, cartState } from "../stores/cart";

import "../styles/globals.css";

import { Header } from "../components/layouts/Header/Header";

function InitApp() {
  const setCart = useSetRecoilState(cartState);

  useEffect(() => {
    const cookies = parseCookies();

    if (cookies.cart !== undefined) {
      const cookiesCart: Cart = JSON.parse(cookies.cart);

      if (cookiesCart.products.length > 0) {
        setCart({
          products: cookiesCart.products
        });
      }
    }
  }, [setCart]);
  return null;
}

function WatchCart() {
  const cart = useRecoilValue(cartState);

  useEffect(() => {
    setCookie(null, "cart", JSON.stringify(cart));
  }, [cart]);
  return null;
}

function MyApp({ Component, pageProps }: AppProps) {
  // ここだとRecoilRootの外側になるので、useRecoilStateなどが使えない!!
  return (
    <RecoilRoot>
      <Header />
      <Component {...pageProps} />;
      <InitApp />
      <WatchCart />
    </RecoilRoot>
  );
}

export default MyApp;

MyApp内のコンポーネントでRecoilを永続化しようとするとRecoilRootの外側に処理を書くことになってしまうので、
別関数で定義しMyAppに渡していくようにしています。

あとはuseEffectでカートの情報が0でもCookiesに値が保存されている場合、
setCartでRecoilの値をCookiesで更新してあげるようにします。

一つのuseEffectの関数で書くと、無限ループが起こってしまう為、
InitAppとWatchCartの関数に分けて実装しています。

これでカートの中身がページ離脱時やリロード時でも保持できるようになりました!!

お疲れさまでした!!!

Discussion