🍇

【24日目】『リーダブルコード』を意識してNext.js/Reactをリファクタリングしてみた

に公開

技術ブログ24日目。
本日は、Next.js/Reactの読みやすさを追求します。

〇課題 ショッピングカートの注文確認画面
修正前のコードは、カート内の商品一覧を表示し、小計・消費税・送料・割引を計算して最終的な合計金額を算出しています。計算ロジックと表示ロジックが混ざり合い、非常にメンテナンスがしにくい状態です。

〇修正前コード(動くけれど読みにくいコード)

// src/app/shop/checkout/page.js
"use client";

import { useState, useEffect } from "react";

export default function CheckoutPage() {
  const [items, setItems] = useState([]);
  const [coupon, setCoupon] = useState("");

  useEffect(() => {
    // カートの商品を取得(モックデータ)
    setItems([
      { id: 1, name: "技術書", price: 3000, quantity: 2 },
      { id: 2, name: "キーボード", price: 12000, quantity: 1 },
    ]);
  }, []);

  return (
    <div className="p-10">
      <h1 className="text-2xl font-bold mb-6">注文確認</h1>

      <div className="space-y-4">
        {items.map((i) => (
          <div key={i.id} className="flex justify-between border-b pb-2">
            <span>{i.name} x {i.quantity}</span>
            <span>{(i.price * i.quantity).toLocaleString()}円</span>
          </div>
        ))}
      </div>

      <div className="mt-8 p-4 bg-gray-50 rounded">
        {/* ロジックがJSXの中に直接書かれていて、一目で計算内容がわからない */}
        <p>小計: {items.reduce((acc, i) => acc + i.price * i.quantity, 0).toLocaleString()}円</p>
        
        <p>消費税 (10%): {Math.floor(items.reduce((acc, i) => acc + i.price * i.quantity, 0) * 0.1).toLocaleString()}円</p>

        {/* 送料の判定マジックナンバー */}
        <p>送料: {items.reduce((acc, i) => acc + i.price * i.quantity, 0) >= 10000 ? 0 : 500}円 
          {items.reduce((acc, i) => acc + i.price * i.quantity, 0) < 10000 && (
            <span className="text-sm text-red-500"> (あと{(10000 - items.reduce((acc, i) => acc + i.price * i.quantity, 0)).toLocaleString()}円で送料無料)</span>
          )}
        </p>

        {/* 合計金額の計算が巨大すぎてバグの温床 */}
        <div className="text-xl font-bold mt-4 border-t pt-2">
          合計: {(
            items.reduce((acc, i) => acc + i.price * i.quantity, 0) + 
            Math.floor(items.reduce((acc, i) => acc + i.price * i.quantity, 0) * 0.1) + 
            (items.reduce((acc, i) => acc + i.price * i.quantity, 0) >= 10000 ? 0 : 500)
          ).toLocaleString()}円
        </div>
      </div>
    </div>
  );
}

〇リファクタリングの指針
名著『リーダブルコード』に基づき、以下の点を意識しました。
一度に一つのタスクを行う
計算(ロジック)と表示(JSX)を分離。
小計、税金、送料を独立した変数で算出し、最後に合計を出す流れに整える。

説明的な変数の導入
何度も登場する items.reduce(...) を subtotal という名前に集約し、コードの意味を明確にする。

マジックナンバーの定数化
税率(0.1)や送料無料ライン(10000)などに名前をつけ、ビジネスルールの変更に強くする。

Custom Hookへの抽出
金額計算のロジックを useCart フックに抽出し、コンポーネントをスッキリさせる。

〇修正後のコード

"use client";

import { useState, useEffect, useMemo } from "react";

// 1. 定数・設定の定義(マジックナンバーを排除)
const TAX_RATE = 0.1;
const FREE_SHIPPING_THRESHOLD = 10000;
const STANDARD_SHIPPING_FEE = 500;

// 2. Custom Hook:useCart(計算とデータの管理)
function useCart() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    // 実際はAPI等から取得
    setItems([
      { id: 1, name: "技術書", price: 3000, quantity: 2 },
      { id: 2, name: "キーボード", price: 12000, quantity: 1 },
    ]);
  }, []);

  // 小計の計算(reduceを一箇所に集約)
  const subtotal = useMemo(() => {
    return items.reduce((acc, item) => acc + item.price * item.quantity, 0);
  }, [items]);

  // 消費税の計算
  const tax = useMemo(() => Math.floor(subtotal * TAX_RATE), [subtotal]);

  // 送料の計算
  const shippingFee = useMemo(() => {
    return subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_FEE;
  }, [subtotal]);

  // 合計金額の計算
  const total = useMemo(() => subtotal + tax + shippingFee, [subtotal, tax, shippingFee]);

  // 送料無料までの残り金額
  const amountToFreeShipping = Math.max(0, FREE_SHIPPING_THRESHOLD - subtotal);

  return {
    items,
    subtotal,
    tax,
    shippingFee,
    total,
    amountToFreeShipping,
  };
}

// 3. メインコンポーネント(見た目の配置)
export default function CheckoutPage() {
  const { items, subtotal, tax, shippingFee, total, amountToFreeShipping } = useCart();

  return (
    <div className="p-10 max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold mb-6 border-b pb-2">注文確認</h1>

      {/* 商品一覧 */}
      <div className="space-y-4 mb-8">
        {items.map((item) => (
          <div key={item.id} className="flex justify-between text-gray-700">
            <span>{item.name} x {item.quantity}</span>
            <span>{(item.price * item.quantity).toLocaleString()}円</span>
          </div>
        ))}
      </div>

      {/* 金額詳細エリア */}
      <div className="p-6 bg-gray-50 rounded-lg space-y-3">
        <SummaryRow label="小計" value={subtotal} />
        <SummaryRow label={`消費税 (${TAX_RATE * 100}%)`} value={tax} />
        
        <div className="flex justify-between items-center">
          <span className="text-gray-600">送料</span>
          <div className="text-right">
            <span className="font-medium">{shippingFee.toLocaleString()}円</span>
            {amountToFreeShipping > 0 && (
              <p className="text-xs text-red-500 mt-1">
                あと{amountToFreeShipping.toLocaleString()}円で送料無料
              </p>
            )}
          </div>
        </div>

        <div className="text-xl font-bold mt-4 border-t pt-4 flex justify-between text-blue-900">
          <span>合計金額</span>
          <span>{total.toLocaleString()}円</span>
        </div>
      </div>
    </div>
  );
}

// 4. 表示用サブコンポーネント
function SummaryRow({ label, value }) {
  return (
    <div className="flex justify-between text-gray-600">
      <span>{label}</span>
      <span>{value.toLocaleString()}円</span>
    </div>
  );
}

〇主な修正ポイント
タスクの分離
一つの長い式で行われていた「足し算・税金計算・送料判定」を分割。階段を一段ずつ登るように計算を整理したことで、ミスが起きにくい構造にしました。

重複計算の統合
4回書かれていた items.reduce を subtotal という変数に集約。
パフォーマンス向上と可読性向上を同時に実現しました。

ビジネスルールの定数化
税率や送料ラインを定数化。将来のルール変更時も、定義箇所を一箇所直すだけで安全に修正が完了します。

宣言的なUI(JSXのクリーン化)
JSXから計算ロジックを完全に排除。「何を表示するか」だけが書かれた、デザイン変更に強いコードになりました。

〇参照先
▼公式ドキュメント
https://react.dev/learn/synchronizing-with-effects

https://react.dev/learn/rendering-lists

https://react.dev/reference/react/useMemo

▼書籍
リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック

以上

Discussion