💬

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

に公開1

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

〇課題 ランニング記録管理画面の改善
走行データの管理画面です。機能は正しく動きますが、計算式がJSX(HTML部分)の中に直接書かれており、非常に解読しづらい状態になっています。

〇修正前コード(動くけれど読みにくいコード)
src/app/fitness/stats/page.tsx

// src/app/fitness/stats/page.tsx
"use client";

import { useState, useEffect } from "react";

export default function RunStats() {
  const [d, setD] = useState<any[]>([]);
  const [f, setF] = useState(0); // 最小距離のフィルター

  useEffect(() => {
    fetch("/api/runs")
      .then((res) => res.json())
      .then((data) => setD(data));
  }, []);

  return (
    <div className="p-10">
      <h1 className="text-2xl mb-4">マイランニングログ</h1>
      <input 
        type="number" 
        value={f} 
        onChange={(e) => setF(Number(e.target.value))} 
        placeholder="最小距離(km)"
        className="border p-2 mb-4"
      />

      <div className="space-y-4">
        {d
          .filter((i) => i.dist >= f)
          .map((i) => (
            <div key={i.id} className="border p-4 rounded shadow">
              <p>日付: {i.dt}</p>
              <p>距離: {i.dist} km</p>
              {/* 複雑な計算がJSXに直接書かれている(ペース計算と判定) */}
              <p>
                ペース: {(i.dur / i.dist).toFixed(2)} /km 
                {i.dur / i.dist < 5 ? (
                  <span className="text-green-500"> (速い!)</span>
                ) : i.dur / i.dist > 7 ? (
                  <span className="text-red-500"> (ゆっくり)</span>
                ) : (
                  <span> (平均的)</span>
                )}
              </p>
              {/* 消費カロリー計算(体重60kgと仮定したマジックナンバー) */}
              <p>推定消費カロリー: {Math.floor(i.dist * 60)} kcal</p>
            </div>
          ))}
      </div>
    </div>
  );
}

〇リファクタリングの指針
名著『リーダブルコード』に基づき、以下の点を意識しました。
説明的な変数(要約変数)の導入
計算結果に pace などの名前をつけ、式が何を表しているのか明確にする。

マジックナンバーを排除
カロリー計算に使う体重やペースの判定基準を定数として定義する。

下位の問題を抽出(Custom Hook / コンポーネント分割)
データ管理を useActivityLog へ抽出し、個別の表示を RunCard コンポーネントに分離する。

制御フローを読みやすく整理
三項演算子のネストを排除し、ガード節(if文での早期リターン)を使って整理する。

〇修正後のコード

"use client";

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

//  1. 定数・型定義 
const USER_WEIGHT_KG = 60;
const PACE_LIMIT = {
  FAST: 5,
  SLOW: 7,
};

interface RunRecord {
  id: number;
  distance: number;  // 距離(km)
  duration: number;  // 時間(分)
  date: string;
}

//  2. Custom Hook(データ管理と加工) 
function useActivityLog() {
  const [logs, setLogs] = useState<RunRecord[]>([]);
  const [minDistance, setMinDistance] = useState(0);

  useEffect(() => {
    fetch("/api/runs")
      .then((res) => res.json())
      .then((data) => setLogs(data));
  }, []);

  // フィルター処理をメモ化(パフォーマンス向上)
  const filteredLogs = useMemo(() => {
    return logs.filter((log) => log.distance >= minDistance);
  }, [logs, minDistance]);

  return {
    filteredLogs,
    minDistance,
    setMinDistance,
  };
}

//  3. メインコンポーネント(全体のレイアウト) 
export default function RunStatsPage() {
  const { filteredLogs, minDistance, setMinDistance } = useActivityLog();

  return (
    <div className="p-10 space-y-6">
      <header>
        <h1 className="text-2xl font-bold">マイランニングログ</h1>
      </header>

      <section className="bg-gray-50 p-4 rounded">
        <label className="block text-sm font-medium mb-1">表示する最小距離 (km)</label>
        <input 
          type="number" 
          value={minDistance} 
          onChange={(e) => setMinDistance(Number(e.target.value))} 
          className="border p-2 rounded w-32"
        />
      </section>

      <div className="grid gap-4">
        {filteredLogs.map((log) => (
          <RunCard key={log.id} log={log} />
        ))}
      </div>
    </div>
  );
}

//  4. サブコンポーネント(個別の記録表示) 
function RunCard({ log }: { log: RunRecord }) {
  // ペースの計算(分/km)
  const pace = log.duration / log.distance;
  
  // 消費カロリーの計算(簡易式:体重 × 距離)
  const estimatedCalories = Math.floor(log.distance * USER_WEIGHT_KG);

  // ペースに応じたラベルの取得
  const getPaceLabel = () => {
    if (pace < PACE_LIMIT.FAST) return <span className="text-green-500 font-bold"> (速い!)</span>;
    if (pace > PACE_LIMIT.SLOW) return <span className="text-red-500"> (ゆっくり)</span>;
    return <span className="text-gray-500"> (平均的)</span>;
  };

  return (
    <div className="border p-4 rounded-lg shadow-sm bg-white flex justify-between items-center">
      <div className="space-y-1">
        <p className="text-sm text-gray-500">{log.date}</p>
        <p className="text-lg font-semibold">{log.distance.toFixed(1)} km</p>
      </div>
      
      <div className="text-right space-y-1">
        <p>
          ペース: {pace.toFixed(2)} /km
          {getPaceLabel()}
        </p>
        <p className="text-sm text-gray-600">
          消費目安: {estimatedCalories.toLocaleString()} kcal
        </p>
      </div>
    </div>
  );
}

〇主な修正ポイント
定数化でメンテナンス性向上
体重やペースの基準値を一箇所にまとめました。将来、体重の設定機能を追加したくなった際も、どこを直せばよいか一目瞭然です。

Custom Hookによる関心の分離
データの取得やフィルタリングを useActivityLog に閉じ込めました。これにより、メインのコンポーネントは見た目のレイアウトだけに集中できます。これを関心の分離と呼びます。

説明的な命名
i.dur / i.dist に pace という名前をつけました。
コードを読み返す際に計算の意図を思い出すコストが激減します。

ガード節でネストを解消
三項演算子のネストを getPaceLabel 関数へ移し、if文で整理しました。上から順に読むだけで条件が把握できる、直線的なコードになりました。

〇参照先
▼公式ドキュメント
https://react.dev/reference/react/useMemo

https://react.dev/learn/reusing-logic-with-custom-hooks

https://nextjs.org/docs/app/api-reference/config/typescript

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

以上

Discussion

Katsuyuki KarasawaKatsuyuki Karasawa

リーダブルコードと直接的には関係ないかもしれませんが、典型的なFetch-on-renderをやってしまっています。

"use client"があるということは、RSCを使用していると思うので、Server Componentでのデータフィッチにするべきかなと思います。

お節介かもしれないですが、少し気になったので...🙏

  useEffect(() => {
    fetch("/api/runs")
      .then((res) => res.json())
      .then((data) => setLogs(data));
  }, []);