🍣

【32日目】『プリンシプル オブ プログラミング』を意識してNext.js/Reactをリファクタリングしてみた

に公開

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

〇課題 
ユーザープロフィール管理画面の改善
ユーザー情報を取得し、そのデータに基づいて「会員ランク」を判定、さらにバリデーションと表示をすべて1つのコンポーネントで行っています。細かい文字列操作(低レベル)と画面レイアウト(高レベル)が混ざり合っており、何をしているか把握するのに時間がかかる状態です。

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

// src/app/profile/settings/page.tsx
"use client";

import { useState, useEffect } from "react";

export default function UserSettings() {
  const [user, setUser] = useState<any>(null);
  const [email, setEmail] = useState("");

  useEffect(() => {
    // ユーザーデータの取得
    fetch("/api/user")
      .then((res) => {
        if (res.status === 200) {
          return res.json();
        }
      })
      .then((data) => {
        setUser(data);
        setEmail(data.email);
      });
  }, []);

  const handleSave = () => {
    // バリデーションの低レベルなロジックがここに混ざっている
    if (!email || email.indexOf("@") === -1 || email.length < 5) {
      alert("不適切なメールアドレスです");
      return;
    }
    // 保存処理
    console.log("Saving...", email);
  };

  if (!user) return <div>Loading...</div>;

  // 会員ランクの判定ロジック(SLAPに反している)
  let rankName = "一般";
  let rankColor = "text-gray-500";
  if (user.isPremium) {
    if (user.points > 10000) {
      rankName = "ダイヤモンド";
      rankColor = "text-blue-600";
    } else {
      rankName = "プレミアム";
      rankColor = "text-green-600";
    }
  }

  return (
    <div className="p-8 space-y-4">
      <h1 className="text-xl font-bold">アカウント設定</h1>
      
      <div className="border p-4 rounded">
        <p>現在のランク: <span className={rankColor}>{rankName}</span></p>
        <p>保有ポイント: {user.points} pt</p>
      </div>

      <div className="flex flex-col space-y-2">
        <label>メールアドレス</label>
        <input 
          value={email} 
          onChange={(e) => setEmail(e.target.value)}
          className="border p-2 rounded"
        />
      </div>

      <button 
        onClick={handleSave}
        className="bg-black text-white px-4 py-2 rounded"
      >
        設定を保存
      </button>
    </div>
  );
}

〇リファクタリングの指針
名著『プリンシプル オブ プログラミング』に基づき、以下の点を意識しました。

  1. SLAP (Single Level of Abstraction Principle)
    「ランク判定の条件」という詳細なロジックと、「画面レイアウト」という全体像を混ぜず、抽象化レベルを統一します。

  2. SRP (Single Responsibility Principle)
    「データ取得」「バリデーション」「表示」の責任を、Hookや関数に適切に分割します。

  3. マジックナンバーの排除
    しきい値(10000)を定数化し、コードにドキュメントとしての役割を持たせます。

〇修正後のコード

"use client";

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

//  1. 定数・型定義(マジックナンバーの排除) 
const RANK_THRESHOLDS = {
  DIAMOND: 10000,
};

interface User {
  id: number;
  email: string;
  points: number;
  isPremium: boolean;
}

//  2. ドメインロジック

// メールアドレスの形式チェック
const isValidEmail = (email: string): boolean => {
  const minLength = 5;
  return email.includes("@") && email.length >= minLength;
};

// 会員ランクの判定(計算ロジックの抽出)
const getMemberRank = (user: User) => {
  if (!user.isPremium) {
    return { name: "一般", color: "text-gray-500" };
  }
  
  const isDiamond = user.points > RANK_THRESHOLDS.DIAMOND;
  return isDiamond 
    ? { name: "ダイヤモンド", color: "text-blue-600" }
    : { name: "プレミアム", color: "text-green-600" };
};

//  3. Custom Hook(データの取得と状態管理:SRP) 
function useUserProfile() {
  const [user, setUser] = useState<User | null>(null);
  const [email, setEmail] = useState("");

  useEffect(() => {
    fetch("/api/user")
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setEmail(data.email);
      });
  }, []);

  const saveProfile = (newEmail: string) => {
    if (!isValidEmail(newEmail)) {
      alert("不適切なメールアドレスです");
      return;
    }
    console.log("Saving...", newEmail);
  };

  const memberRank = useMemo(() => {
    return user ? getMemberRank(user) : null;
  }, [user]);

  return { user, email, setEmail, saveProfile, memberRank };
}

//  4. メインコンポーネント(表示の構成に集中:SLAP) 
export default function UserSettingsPage() {
  const { user, email, setEmail, saveProfile, memberRank } = useUserProfile();

  if (!user || !memberRank) return <div>Loading...</div>;

  return (
    <div className="p-8 space-y-6 max-w-md">
      <h1 className="text-2xl font-bold">アカウント設定</h1>
      
      <RankInfoCard 
        rankName={memberRank.name} 
        rankColor={memberRank.color} 
        points={user.points} 
      />

      <div className="flex flex-col space-y-2">
        <label className="font-medium">メールアドレス</label>
        <input 
          value={email} 
          onChange={(e) => setEmail(e.target.value)}
          className="border p-2 rounded shadow-sm"
        />
      </div>

      <button 
        onClick={() => saveProfile(email)}
        className="w-full bg-blue-600 text-white py-2 rounded font-bold hover:bg-blue-700 transition"
      >
        設定を保存
      </button>
    </div>
  );
}

//  5. サブコンポーネント 
function RankInfoCard({ rankName, rankColor, points }: { rankName: string, rankColor: string, points: number }) {
  return (
    <div className="border p-4 rounded-lg bg-gray-50">
      <p className="text-sm text-gray-600">現在のランク</p>
      <p className={`text-xl font-bold ${rankColor}`}>{rankName}</p>
      <p className="text-sm text-gray-500 mt-2">保有ポイント: {points.toLocaleString()} pt</p>
    </div>
  );
}

〇主な修正ポイント
SLAPの導入: 修正後は、メインコンポーネントが isValidEmail などの関数を呼び出すだけになりました。詳細な「どうやるか」が隠され、「何をしているか」という物語のように読めるようになります。

SRPの徹底: Hookはデータ、関数はビジネスルール、コンポーネントは見た目、と責任を明確に分離しました。

ガード節による平坦化: ランク判定のネストを解消し、上から下へ直線的に理解できるロジックに整えました。

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

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

▼書籍
プリンシプル オブ プログラミング 3年目までに身につけたい 一生役立つ101の原理原則

以上

Discussion