Next.js, Recoil, nookiesを使用して、カート機能を実装しよう!!
Next.jsとRecoil, nookiesを使用してショッピングサイトにあるカート機能を実装していきます。
今回の作るのも
こんな感じのショッピングサイトのカート機能を実装していきます。
github でソースコードが確認できます。
実装イメージとしては下記です。
・商品の取得は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という機能を使っていきます。
合計金額はカートの中の
商品金額 * 数量を元の合計価格にどんどん加算していく形で算出できますね。
// 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はこちら
カートへ追加するのボタンをクリックする際に先ほど作成した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はこちら
こちらの処理は非常に簡単で
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を扱えるライブラリーです。
カートの情報はどのページでも失われて欲しくないので、_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