2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React/Next.js】「とりあえず動く」から卒業! パズルゲームを題材に学ぶ「プロのリファクタリング術」

2
Last updated at Posted at 2025-12-29

0.はじめに

前回の記事では、React Hooks(useState, useRef, useEffect)を駆使して、「箱入り娘」パズルを1時間で作りきりました。

とりあえずpage.tsx にすべてを詰め込み、動くものは作れました。

image.png

しかし、現場のプロとしてこれで提出したらどうなるでしょうか?
レビューで次の通り指摘されること間違いなしです。

  • 「page.tsx が肥大化しすぎて読めない」
  • 「ロジックと表示が混ざってテストできない」
  • 「再利用性がない」

今回は、前回の記事で作成した「神クラス(God Component)」状態の page.tsx を、標準的なReactのディレクトリ構成(コンポーネント指向に基づいた機能分割) にリファクタリングし、「プロとして恥ずかしくないコード」にする過程を解説します。

1.現状の課題:Fat Page Component

まずは、前回作成した main ブランチの状態を確認しましょう。 src/app/page.tsx に以下の要素がすべて記述されています。

============================
   ソースコード全体を表示(折りたたみ)
 =============================
"use client";

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

// ================================================================
//                            定数・型定義
// ================================================================

// --- 盤面 ---
const BOARD_LAYOUT = [
  [1, 1, 1, 1, 1, 1, 1, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 1, 1, 9, 9, 1, 1, 1],
];
const CELL_SIZE = 50;
const ROWS = BOARD_LAYOUT.length;
const COLS = BOARD_LAYOUT[0].length;

// --- 駒 ---
type Piece = {
  id: number;
  name: string;
  x: number;
  y: number;
  width: number;
  height: number;
  colorClass: string;
};

// prettier-ignore
const INITIAL_PIECES: Piece[] = [
{ id: 1, name: '', x: 2, y: 1, width: 1, height: 2, colorClass: 'bg-blue-800' },
  { id: 2, name: '', x: 3, y: 1, width: 2, height: 2, colorClass: 'bg-pink-300' },
  { id: 3, name: '', x: 5, y: 1, width: 1, height: 2, colorClass: 'bg-rose-700' },
  { id: 4, name: '手代', x: 1, y: 3, width: 1, height: 1, colorClass: 'bg-green-600' },
  { id: 5, name: '大番頭', x: 2, y: 3, width: 4, height: 1, colorClass: 'bg-purple-800' },
  { id: 6, name: '兄嫁', x: 6, y: 3, width: 1, height: 1, colorClass: 'bg-emerald-800' },
  { id: 7, name: '丁稚', x: 1, y: 4, width: 1, height: 1, colorClass: 'bg-gray-400' },
  { id: 8, name: '女中', x: 2, y: 4, width: 2, height: 1, colorClass: 'bg-orange-400' },
  { id: 9, name: '番頭', x: 4, y: 4, width: 2, height: 1, colorClass: 'bg-indigo-600' },
  { id: 10, name: '丁稚', x: 6, y: 4, width: 1, height: 1, colorClass: 'bg-gray-400' },
  { id: 11, name: '番犬', x: 1, y: 5, width: 1, height: 1, colorClass: 'bg-stone-500' },
  { id: 12, name: '祖父', x: 2, y: 5, width: 2, height: 1, colorClass: 'bg-teal-700' },
  { id: 13, name: '祖母', x: 4, y: 5, width: 2, height: 1, colorClass: 'bg-teal-600' },
  { id: 14, name: '丁稚', x: 6, y: 5, width: 1, height: 1, colorClass: 'bg-gray-400' },
];

// ================================================================
//                            コンポーネント
// ================================================================
export default function Demo() {
  const [pieces, setPieces] = useState<Piece[]>(INITIAL_PIECES);
  const [selectedId, setSelectedId] = useState<number | null>(null);

  const [elapsed, setElapsed] = useState(0);
  const [isCleared, setIsCleared] = useState(false);
  const [message, setMessage] = useState("");

  const timerRef = useRef<NodeJS.Timeout | null>(null);
  const dragState = useRef({
    startX: 0, // クリック開始したX座標
    startY: 0, // クリック開始したY座標
  });

  // 起動時・終了時処理
  useEffect(() => {
    // クリア済みならタイマーを進めない
    if (isCleared) return;

    timerRef.current = setInterval(() => {
      setElapsed((prev) => prev + 1);
    }, 1000);

    // 終了時に必ず実行される処理
    // 画面が消えるときにタイマーを破棄(クリーンアップ)
    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, [isCleared]); // isClearedが変化したら再評価

  // リセット処理
  const initGame = () => {
    setPieces(INITIAL_PIECES);
    setElapsed(0);
    setIsCleared(false);
    setMessage("");
    setSelectedId(null);
  };

  // ▼ 追加:駒を押したときの処理
  const handlePointerDown = (e: React.PointerEvent, id: number) => {
    // クリア済みなら操作無効
    if (isCleared) return;

    setMessage(""); // クリック時にメッセージを一旦消す
    setSelectedId(id);
    e.preventDefault();

    // ▼ 追加:ポインター(マウス・指)をこの要素にロックする
    e.currentTarget.setPointerCapture(e.pointerId);

    // ▼ 追加:開始位置を記憶
    dragState.current = {
      startX: e.clientX,
      startY: e.clientY,
    };
  };

  // --------------------------------------------------------
  // 衝突判定ロジック
  // --------------------------------------------------------
  const checkCollision = (
    target: Piece,
    newX: number,
    newY: number
  ): { allowed: boolean; msg?: string; win?: boolean } => {
    // 1. 盤外チェック
    if (newX < 0 || newX + target.width > COLS) return { allowed: false };
    if (newY < 0 || newY + target.height > ROWS) return { allowed: false };

    // 2. 壁・出口チェック
    let hitExit = false;
    for (let y = 0; y < target.height; y++) {
      for (let x = 0; x < target.width; x++) {
        const cell = BOARD_LAYOUT[newY + y][newX + x];
        if (cell === 1) return { allowed: false }; // 壁
        if (cell === 9) hitExit = true; // 出口
      }
    }

    // 出口に入った場合の判定
    if (hitExit) {
      if (target.name === "") {
        return { allowed: true, win: true };
      } else {
        return { allowed: false, msg: "娘以外は出られません!" };
      }
    }

    // 3. 他の駒との衝突チェック
    const collision = pieces.some((p) => {
      if (p.id === target.id) return false;
      return (
        newX < p.x + p.width &&
        newX + target.width > p.x &&
        newY < p.y + p.height &&
        newY + target.height > p.y
      );
    });

    if (collision) return { allowed: false };

    return { allowed: true };
  };

  // ポインター移動
  const handlePointerMove = (e: React.PointerEvent) => {
    if (selectedId === null || isCleared) return;

    const currentX = e.clientX;
    const currentY = e.clientY;
    const startX = dragState.current.startX;
    const startY = dragState.current.startY;
    const THRESHOLD = CELL_SIZE / 2;

    let dx = 0;
    let dy = 0;

    if (Math.abs(currentX - startX) > THRESHOLD) {
      dx = currentX > startX ? 1 : -1;
    } else if (Math.abs(currentY - startY) > THRESHOLD) {
      dy = currentY > startY ? 1 : -1;
    }
    // 移動量に達してなければ終了
    if (dx === 0 && dy === 0) return;

    const target = pieces.find((p) => p.id === selectedId);
    if (target) {
      const newX = target.x + dx;
      const newY = target.y + dy;

      const result = checkCollision(target, newX, newY);

      // メッセージがあれば表示
      if (result.msg) setMessage(result.msg);
      
      if (result.allowed) {
        // 勝利判定
        if (result.win) {
            setIsCleared(true);
            setMessage("🎉ゲームクリア!🎉");
        }

        setPieces((prev) =>
          prev.map((p) => (p.id === selectedId ? { ...p, x: newX, y: newY } : p))
        );
        dragState.current = { startX: currentX, startY: currentY };
      }
    }
  };

  // ポインターアップ
  const handlePointerUp = (e: React.PointerEvent) => {
    setSelectedId(null);
    dragState.current = { startX: 0, startY: 0 };
    e.currentTarget.releasePointerCapture(e.pointerId);
  };

  return (
    <div className="flex flex-col items-center justify-center p-10 min-h-screen bg-gray-50">
      {/* ヘッダーエリア */}
      <div className="mb-6 flex flex-col items-center gap-2">
        <h1 className="text-2xl font-bold text-gray-800">箱入り娘</h1>
        <p className="text-xl text-gray-800">娘だけを出してね</p>
        
        <div className="flex items-center gap-4">
          <div className="text-xl text-gray-800 font-mono bg-white px-4 py-1 rounded border">
            経過時間: {elapsed}
          </div>
          <button
            onClick={initGame}
            className="px-4 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors shadow"
          >
            リセット
          </button>
        </div>

        {/* メッセージエリア */}
        <div className="h-8 flex items-center">
            {message && (
                <span className={`font-bold ${isCleared ? "text-red-500 text-xl animate-bounce" : "text-red-600"}`}>
                    {message}
                </span>
            )}
        </div>
      </div>

      {/* 盤面(ここをCSS Gridにする) */}
      <div
        className="relative grid w-fit border-2 border-black"
        style={{
          // ここで列と行の定義をするのがポイント
          gridTemplateColumns: `repeat(${COLS}, ${CELL_SIZE}px)`,
          gridTemplateRows: `repeat(${ROWS}, ${CELL_SIZE}px)`,
        }}
      >
        {/* 2重ループでセルを描画 */}
        {BOARD_LAYOUT.map((row, y) =>
          row.map((cell, x) => (
            <div
              key={`${y}-${x}`}
              className={`
      flex items-center justify-center text-xs text-gray-400
      ${cell === 0 ? "bg-amber-100 border border-amber-100" : ""}
      ${cell === 1 ? "bg-stone-600 border border-stone-600" : ""}
      ${cell === 9 ? "bg-red-100/50" : ""}
    `}
            >
              {cell === 9 && (
                <span className="text-neutral-800/50 font-bold">玄関</span>
              )}
            </div>
          ))
        )}

        {/* 駒描画 */}
        {pieces.map((p) => (
          <div
            key={p.id}
            onPointerDown={(e) => handlePointerDown(e, p.id)}
            onPointerMove={handlePointerMove}
            onPointerUp={handlePointerUp}
            className={`
      absolute flex items-center justify-center
      border-2 border-white/50 rounded shadow-md
      text-white font-bold text-sm select-none
      ${p.colorClass}
            /* ▼ 選択中のスタイル適用 (リング表示 + 最前面へ) */
              ${
                selectedId === p.id ? "ring-4 ring-yellow-400 z-10" : "z-0"
              }    `}
            style={{
              left: p.x * CELL_SIZE,
              top: p.y * CELL_SIZE,
              width: p.width * CELL_SIZE,
              height: p.height * CELL_SIZE,
            }}
          >
            {p.name}
          </div>
        ))}
      </div>
    </div>
  );
}
  1. 型定義: Piece 型
  2. 定数(ゲームの初期情報): BOARD_LAYOUT, INITIAL_PIECES
  3. 状態管理: pieces, selectedId, timer などの useState
  4. 複雑なロジック: 衝突判定 checkCollision
  5. イベントハンドラ: ドラッグ&ドロップ処理 handlePointerMove
  6. 表示(JSX): 盤面描画、駒の描画、タイマー表示、スタイル定義

これでは、例えば「タイマーのロジックを別のゲームで使いたい」と思っても切り出せませんし、衝突判定のバグ修正をするのにもHTMLの中に埋もれたコードを探す必要があります。

2.ゴール:責務の分離(Separation of Concerns)

今回は以下のディレクトリ構成を目指してファイルを分割します。
src/
├─ app/
│ └─ page.tsx <-- ほぼ表示と呼び出しのみにする
├─ components/ <-- 見た目(UI)を担当
│ ├─ Header.tsx
│ ├─ Board.tsx
│ └─ Piece.tsx
├─ hooks/ <-- ロジック(状態・計算)を担当
│ ├─ useGameLogic.ts
│ └─ useTimer.ts
├─ types/ <-- 型定義
│ └─ index.ts
└─ constants/ <-- 定数
└─ gameConfig.ts

3.全体の構造をどう変えたいのか?(イメージ図)

image.png

4.修正後のデータフロー(イメージ図)

image.png

5.修正手順①:型と定数を逃がす

まず、ファイルの先頭で場所を取っている定数と型定義を別ファイルに移動させます。これだけでメインファイルが数十行軽くなります。

src/types/index.ts

export type Piece = {
  id: number;
  name: string;
  x: number;
  y: number;
  width: number;
  height: number;
  colorClass: string;
};

src/constants/gameConfig.ts

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

// --- 盤面 ---
export const BOARD_LAYOUT = [
  [1, 1, 1, 1, 1, 1, 1, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 0, 0, 0, 0, 0, 0, 1],
  [1, 1, 1, 9, 9, 1, 1, 1],
];
export const CELL_SIZE = 50;
export const ROWS = BOARD_LAYOUT.length;
export const COLS = BOARD_LAYOUT[0].length;

// prettier-ignore
export const INITIAL_PIECES: Piece[] = [
{ id: 1, name: '', x: 2, y: 1, width: 1, height: 2, colorClass: 'bg-blue-800' },
  { id: 2, name: '', x: 3, y: 1, width: 2, height: 2, colorClass: 'bg-pink-300' },
  { id: 3, name: '', x: 5, y: 1, width: 1, height: 2, colorClass: 'bg-rose-700' },
  { id: 4, name: '手代', x: 1, y: 3, width: 1, height: 1, colorClass: 'bg-green-600' },
  { id: 5, name: '大番頭', x: 2, y: 3, width: 4, height: 1, colorClass: 'bg-purple-800' },
  { id: 6, name: '兄嫁', x: 6, y: 3, width: 1, height: 1, colorClass: 'bg-emerald-800' },
  { id: 7, name: '丁稚', x: 1, y: 4, width: 1, height: 1, colorClass: 'bg-gray-400' },
  { id: 8, name: '女中', x: 2, y: 4, width: 2, height: 1, colorClass: 'bg-orange-400' },
  { id: 9, name: '番頭', x: 4, y: 4, width: 2, height: 1, colorClass: 'bg-indigo-600' },
  { id: 10, name: '丁稚', x: 6, y: 4, width: 1, height: 1, colorClass: 'bg-gray-400' },
  { id: 11, name: '番犬', x: 1, y: 5, width: 1, height: 1, colorClass: 'bg-stone-500' },
  { id: 12, name: '祖父', x: 2, y: 5, width: 2, height: 1, colorClass: 'bg-teal-700' },
  { id: 13, name: '祖母', x: 4, y: 5, width: 2, height: 1, colorClass: 'bg-teal-600' },
  { id: 14, name: '丁稚', x: 6, y: 5, width: 1, height: 1, colorClass: 'bg-gray-400' },
];

6.修正手順②:ロジックを Custom Hooks に抽出する

ここがリファクタリングの肝です。page.tsx にある useState や checkCollision などの「計算・振る舞い」に関するコードを hooks フォルダへ移動します。

タイマー機能の分離 (src/hooks/useTimer.ts)

useEffect を使ったタイマー処理は、ゲームの本質的なルールとは独立しています。

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

export const useTimer = (isStopped: boolean) => {
  const [elapsed, setElapsed] = useState(0);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  useEffect(() => {
    if (isStopped) return;
    timerRef.current = setInterval(() => {
      setElapsed((prev) => prev + 1);
    }, 1000);
    return () => {
      if (timerRef.current) clearInterval(timerRef.current);
    };
  }, [isStopped]);

  const resetTimer = () => setElapsed(0);

  return { elapsed, resetTimer };
};

ゲームロジックの分離 (src/hooks/useGameLogic.ts)

衝突判定やドラッグ処理などのロジックをまとめます。

import { useState, useRef } from "react";
import { Piece } from "../types";
import {
  INITIAL_PIECES,
  BOARD_LAYOUT,
  CELL_SIZE,
  COLS,
  ROWS,
} from "../constants/gameConfig";

export const useGameLogic = () => {
  const [pieces, setPieces] = useState<Piece[]>(INITIAL_PIECES);
  const [selectedId, setSelectedId] = useState<number | null>(null);
  const [isCleared, setIsCleared] = useState(false);
  const [message, setMessage] = useState("");

  const dragState = useRef({ startX: 0, startY: 0 });

  const checkCollision = (
    target: Piece,
    newX: number,
    newY: number
  ): { allowed: boolean; msg?: string; win?: boolean } => {
    // 1. 盤外チェック
    if (newX < 0 || newX + target.width > COLS) return { allowed: false };
    if (newY < 0 || newY + target.height > ROWS) return { allowed: false };

    // 2. 壁・出口チェック
    let hitExit = false;
    for (let y = 0; y < target.height; y++) {
      for (let x = 0; x < target.width; x++) {
        const cell = BOARD_LAYOUT[newY + y][newX + x];
        if (cell === 1) return { allowed: false }; // 壁
        if (cell === 9) hitExit = true; // 出口
      }
    }

    // 出口に入った場合の判定
    if (hitExit) {
      if (target.name === "") {
        return { allowed: true, win: true };
      } else {
        return { allowed: false, msg: "娘以外は出られません!" };
      }
    }

    // 3. 他の駒との衝突チェック
    const collision = pieces.some((p) => {
      if (p.id === target.id) return false;
      return (
        newX < p.x + p.width &&
        newX + target.width > p.x &&
        newY < p.y + p.height &&
        newY + target.height > p.y
      );
    });

    if (collision) return { allowed: false };

    return { allowed: true };
  };

  const handlePointerDown = (e: React.PointerEvent, id: number) => {
    // クリア済みなら操作無効
    if (isCleared) return;

    setMessage(""); // クリック時にメッセージを一旦消す
    setSelectedId(id);
    e.preventDefault();

    // ▼ 追加:ポインター(マウス・指)をこの要素にロックする
    e.currentTarget.setPointerCapture(e.pointerId);

    // ▼ 追加:開始位置を記憶
    dragState.current = {
      startX: e.clientX,
      startY: e.clientY,
    };
  };

  const handlePointerMove = (e: React.PointerEvent) => {
    if (selectedId === null || isCleared) return;

    const currentX = e.clientX;
    const currentY = e.clientY;
    const startX = dragState.current.startX;
    const startY = dragState.current.startY;
    const THRESHOLD = CELL_SIZE / 2;

    let dx = 0;
    let dy = 0;

    if (Math.abs(currentX - startX) > THRESHOLD) {
      dx = currentX > startX ? 1 : -1;
    } else if (Math.abs(currentY - startY) > THRESHOLD) {
      dy = currentY > startY ? 1 : -1;
    }
    // 移動量に達してなければ終了
    if (dx === 0 && dy === 0) return;

    const target = pieces.find((p) => p.id === selectedId);
    if (target) {
      const newX = target.x + dx;
      const newY = target.y + dy;

      const result = checkCollision(target, newX, newY);

      // メッセージがあれば表示
      if (result.msg) setMessage(result.msg);

      if (result.allowed) {
        // 勝利判定
        if (result.win) {
          setIsCleared(true);
          setMessage("🎉ゲームクリア!🎉");
        }

        setPieces((prev) =>
          prev.map((p) =>
            p.id === selectedId ? { ...p, x: newX, y: newY } : p
          )
        );
        dragState.current = { startX: currentX, startY: currentY };
      }
    }
  };

  const handlePointerUp = (e: React.PointerEvent) => {
    setSelectedId(null);
    dragState.current = { startX: 0, startY: 0 };
    e.currentTarget.releasePointerCapture(e.pointerId);
  };

  const initGame = () => {
    setPieces(INITIAL_PIECES);
    setIsCleared(false);
    setMessage("");
    setSelectedId(null);
  };
  // View(JSX)に必要なものだけをreturnする
  return {
    pieces,
    selectedId,
    isCleared,
    message,
    handlePointerDown,
    handlePointerMove,
    handlePointerUp,
    initGame,
  };
};

これで、ロジック部分は「Reactに依存しない(Viewを知らない)純粋な関数」に近づき、ユニットテストが書きやすくなりました。

7.修正手順③:UIコンポーネントの分割

このゲームアプリの描画は大きく3種類に分割できます。

  • ヘッダー(経過時間やリセットボタンなど)
  • 盤面
  • 各駒
    それぞれをコンポーネントに分離します。

ヘッダー(src/components/Header.tsx)

経過時間やメッセージ、リセットボタンをクリックされたときの「動作」をPropsで受け取ります。

type Props = {
  elapsed: number;
  message: string;
  isCleared: boolean;
  resetGame: () => void;
};

export const HeaderComponent = ({
  elapsed,
  message,
  isCleared,
  resetGame,
}: Props) => {
  return (
    <div>
      {/* ヘッダーエリア */}
      <div className="mb-6 flex flex-col items-center gap-2">
        <h1 className="text-2xl font-bold text-gray-800">箱入り娘</h1>
        <p className="text-xl text-gray-800">娘だけを出してね</p>
      </div>

      <div className="flex items-center gap-4">
        <div className="text-xl text-gray-800 font-mono bg-white px-4 py-1 rounded border">
          経過時間: {elapsed}
        </div>
        <button
          onClick={resetGame}
          className="px-4 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors shadow"
        >
          リセット
        </button>
      </div>

      {/* メッセージエリア */}
      <div className="h-8 flex items-center">
        {message && (
          <span
            className={`font-bold ${
              isCleared ? "text-red-500 text-xl animate-bounce" : "text-red-600"
            }`}
          >
            {message}
          </span>
        )}
      </div>
    </div>
  );
};

盤面(src/components/Board.tsx)

盤面とそこに並ぶ駒たち、さらに駒の動作をPropsで受け取ります。

import { COLS, CELL_SIZE, ROWS, BOARD_LAYOUT } from "../constants/gameConfig";
import { PieceComponent } from "./Piece";
import { Piece } from "../types";

interface BoardComponentProps {
  pieces: Piece[];
  selectedId: number | null;
  handlePointerDown: (e: React.PointerEvent, id: number) => void;
  handlePointerMove: (e: React.PointerEvent) => void;
  handlePointerUp: (e: React.PointerEvent) => void;
}

export const BoardComponent = ({
  pieces,
  selectedId,
  handlePointerDown,
  handlePointerMove,
  handlePointerUp,
}: BoardComponentProps) => {
  return (
    <div
      className="relative grid w-fit border-2 border-black"
      style={{
        gridTemplateColumns: `repeat(${COLS}, ${CELL_SIZE}px)`,
        gridTemplateRows: `repeat(${ROWS}, ${CELL_SIZE}px)`,
      }}
    >
      {/* 背景描画 */}
      {BOARD_LAYOUT.map((row, y) =>
        row.map((cell, x) => (
          <div
            key={`${y}-${x}`}
            className={`
      flex items-center justify-center text-xs text-gray-400
      ${cell === 0 ? "bg-amber-100 border border-amber-100" : ""}
      ${cell === 1 ? "bg-stone-600 border border-stone-600" : ""}
      ${cell === 9 ? "bg-red-100/50" : ""}
    `}
          >
            {cell === 9 && (
              <span className="text-neutral-800/50 font-bold">玄関</span>
            )}
          </div>
        ))
      )}

      {/* 駒描画:ロジックがなくなりスッキリ */}
      {pieces.map((p) => (
        <PieceComponent
          key={p.id}
          piece={p}
          isSelected={selectedId === p.id}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={handlePointerUp}
        />
      ))}
    </div>
  );
};

駒(src/components/Piece.tsx)

最後に、駒の表示をコンポーネント化します。

import { Piece } from "../types";
import { CELL_SIZE } from "../constants/gameConfig";

type Props = {
  piece: Piece;
  isSelected: boolean;
  onPointerDown: (e: React.PointerEvent, id: number) => void;
  onPointerMove: (e: React.PointerEvent) => void;
  onPointerUp: (e: React.PointerEvent) => void;
};

export const PieceComponent = ({
  piece,
  isSelected,
  onPointerDown,
  onPointerMove,
  onPointerUp,
}: Props) => {
  return (
    <div
      onPointerDown={(e) => onPointerDown(e, piece.id)}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      className={`
        absolute flex items-center justify-center
        border-2 border-white/50 rounded shadow-md
        text-white font-bold text-sm select-none
        ${piece.colorClass}
              /* ▼ 選択中のスタイル適用 (リング表示 + 最前面へ) */
                ${isSelected ? "ring-4 ring-yellow-400 z-10" : "z-0"}    `}
      style={{
        left: piece.x * CELL_SIZE,
        top: piece.y * CELL_SIZE,
        width: piece.width * CELL_SIZE,
        height: piece.height * CELL_SIZE,
      }}
    >
      {piece.name}
    </div>
  );
};

8.完成:生まれ変わった page.tsx

リファクタリング後の src/app/page.tsx はこれだけシンプルになります。

"use client";
import { useTimer } from "../hooks/useTimer";
import { useGameLogic } from "../hooks/useGameLogic";
import { BoardComponent } from "../components/Board";
import { HeaderComponent } from "../components/Header";

export default function Demo() {
  // ロジックはHooksから呼び出すだけ
  const {
    pieces,
    selectedId,
    isCleared,
    message,
    handlePointerDown,
    handlePointerMove,
    handlePointerUp,
    initGame,
  } = useGameLogic();
  const { elapsed, resetTimer } = useTimer(isCleared);

  // リセット処理の結合
  const resetGame = () => {
    initGame();
    resetTimer();
  };

  return (
    <div className="flex flex-col items-center justify-center p-10 min-h-screen bg-gray-50">
      <HeaderComponent
        elapsed={elapsed}
        message={message}
        isCleared={isCleared}
        resetGame={resetGame}
      />

      <BoardComponent
        pieces={pieces}
        selectedId={selectedId}
        handlePointerDown={handlePointerDown}
        handlePointerMove={handlePointerMove}
        handlePointerUp={handlePointerUp}
      />
    </div>
  );
}

9.まとめ

「とりあえず動くコード」を構造化することで、以下のメリットが生まれました。

  1. 可読性の向上: page.tsx が「何のロジックか」ではなく「何の構成要素か」を記述する場所になり、見通しが良くなりました。
  2. 保守性: 「駒の動きを変えたい」なら useGameLogic.ts、「デザインを変えたい」なら Piece.tsx と、修正箇所が明確になりました。
  3. 再利用性: タイマー機能などは他のアプリでもそのままコピーして使えます。

プロの現場では、動くものを作るのはスタートラインです。
そこから、未来の自分やチームメンバーのために 「コードを整理整頓(構造化)する」 スキルこそが求められます。
今回のリファクタリング済みのコードもGitHubの別ブランチに上げていますので、ぜひ main ブランチと比較してみてください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?