Áp dụng mô hình Container Component, Presenter Component và Custom Hooks để xây dựng một game cờ caro.
Đầu tiên là cấu trúc thư mục:
src/
├── components/
│ ├── presenter/
│ │ └── Board.tsx # Presenter Component (chỉ hiển thị)
│ └── container/
│ └── GameContainer.tsx # Container Component (chứa logic)
├── hooks/
│ └── useGame.ts # Custom Hook (quản lý toàn bộ state & logic game)
├── types/
│ └── game.ts # Các type chung
├── App.tsx # Entry point của app
└── main.tsx # Bootstrap ReactĐịnh nghĩa kiểu dữ liệu cho game cờ caro (types/game.ts)
export type Player = 'X' | 'O' | null;
export interface GameState {
board: Player[][];
currentPlayer: Player;
winner: Player;
isDraw: boolean;
}Tạo Custom Hook chứa logic (hooks/use-game.ts)
import { useState, useEffect } from "react";
import type { GameState, Player } from "../types/game";
const BOARD_SIZE = 15; // Kích thước bàn cờ 15x15
const WIN_COUNT = 5; // Thắng khi có 5 quân liên tiếp
const INITIAL_BOARD: Player[][] = Array(BOARD_SIZE)
.fill(null)
.map(() => Array(BOARD_SIZE).fill(null));
const STORAGE_KEY = "caro_game_state";
export const useGame = () => {
const [gameState, setGameState] = useState<GameState>({
board: INITIAL_BOARD,
currentPlayer: "X",
winner: null,
isDraw: false,
});
const [history, setHistory] = useState<GameState[]>([]);
const [loading, setLoading] = useState(true);
// Load game khi mount
useEffect(() => {
const loadGame = async () => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (
parsed.gameState?.board &&
parsed.gameState.board.length === BOARD_SIZE
) {
setGameState(parsed.gameState);
setHistory(parsed.history || []);
}
}
} catch (error) {
console.error("Error loading game:", error);
} finally {
setLoading(false);
}
};
loadGame();
}, []);
// Lưu game mỗi khi thay đổi
useEffect(() => {
if (!loading) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ gameState, history }));
}
}, [gameState, history, loading]);
/**
* Xử lý khi người chơi click vào 1 ô trên bàn cờ
*/
const makeMove = (row: number, col: number) => {
if (gameState.board[row][col] || gameState.winner || gameState.isDraw)
return;
setHistory((prev) => [...prev, gameState]);
const newBoard: Player[][] = gameState.board.map((r) => [...r]);
newBoard[row][col] = gameState.currentPlayer;
const winner = checkWinner(newBoard, row, col);
const isDraw = !winner && newBoard.flat().every((cell) => cell !== null);
setGameState({
board: newBoard,
currentPlayer: gameState.currentPlayer === "X" ? "O" : "X",
winner,
isDraw,
});
};
/**
* Undo nước đi gần nhất
*/
const undoMove = () => {
if (history.length === 0) return;
const prevState = history[history.length - 1];
setHistory((prev) => prev.slice(0, -1));
setGameState(prevState);
};
/**
* Reset toàn bộ game về trạng thái ban đầu
*/
const resetGame = () => {
setGameState({
board: INITIAL_BOARD,
currentPlayer: "X",
winner: null,
isDraw: false,
});
setHistory([]);
};
return {
...gameState,
loading,
makeMove,
resetGame,
undoMove,
canUndo: history.length > 0,
};
};
/**
* Kiểm tra người chơi hiện tại có thắng hay không
*/
function checkWinner(
board: Player[][],
row: number,
col: number
): Player | null {
const player = board[row][col];
if (!player) return null;
const directions = [
[0, 1], // ngang
[1, 0], // dọc
[1, 1], // chéo \
[1, -1], // chéo /
];
for (const [dr, dc] of directions) {
let count = 1;
// Đếm về phía trước
let r = row + dr;
let c = col + dc;
while (
r >= 0 &&
r < board.length &&
c >= 0 &&
c < board[0].length &&
board[r][c] === player
) {
count++;
r += dr;
c += dc;
}
// Kiểm tra đầu phía trước có bị chặn không
const blockedForward =
r < 0 ||
r >= board.length ||
c < 0 ||
c >= board[0].length ||
board[r][c] !== null;
// Đếm về phía sau
r = row - dr;
c = col - dc;
while (
r >= 0 &&
r < board.length &&
c >= 0 &&
c < board[0].length &&
board[r][c] === player
) {
count++;
r -= dr;
c -= dc;
}
// Kiểm tra đầu phía sau có bị chặn không
const blockedBackward =
r < 0 ||
r >= board.length ||
c < 0 ||
c >= board[0].length ||
board[r][c] !== null;
if (
count === WIN_COUNT &&
!(blockedForward && blockedBackward)
) {
return player;
}
}
return null;
}
Tạo Presentational Component – Chỉ lo hiển thị (components/presenter/Board.tsx)
import type { Player } from "../../types";
interface BoardProps {
board: Player[][];
onCellClick: (row: number, col: number) => void;
winner: Player;
isDraw: boolean;
currentPlayer: Player;
loading: boolean;
}
export const Board = ({
board,
onCellClick,
winner,
isDraw,
currentPlayer,
loading,
}: BoardProps) => {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-xl">Đang tải ván cờ...</p>
</div>
);
}
return (
<div className="text-center">
<div className="mb-6 text-2xl font-bold">
{winner ? (
<span className="text-green-600">Người thắng: {winner}</span>
) : isDraw ? (
<span className="text-yellow-600">Hòa!</span>
) : (
<span>Tới lượt: <span className="text-green-500">{currentPlayer}</span></span>
)}
</div>
<div className="inline-block border border-gray-900 rounded-2xl shadow-2xl bg-amber-50 overflow-auto">
<div className="flex flex-col">
{board.map((row, rowIndex) => (
<div
key={rowIndex}
className="flex flex-nowrap"
style={{ width: "fit-content" }}
>
{row.map((cell, colIndex) => (
<button
key={`${rowIndex}-${colIndex}`}
className="
w-10 h-10
sm:w-6 sm:h-6
md:w-8 md:h-8
lg:w-8 lg:h-w-8
border border-gray-600
bg-white
text-xl sm:text-2xl md:text-3xl font-bold
flex items-center justify-center
hover:bg-gray-100 active:bg-gray-200
transition-all duration-150
disabled:cursor-not-allowed
"
onClick={() => onCellClick(rowIndex, colIndex)}
disabled={!!winner || isDraw || !!cell}
>
{cell === "X" && (
<span className="text-blue-600 drop-shadow">X</span>
)}
{cell === "O" && (
<span className="text-red-600 drop-shadow">O</span>
)}
</button>
))}
</div>
))}
</div>
</div>
</div>
);
};
Tạo Container Component – Nơi kết nối logic và UI (components/container/GameContainer.tsx)
import { useGame } from "../../hooks/use-game";
import { Board } from "../presenter/Board";
export const GameContainer = () => {
const { board, currentPlayer, winner, isDraw, loading, undoMove, canUndo, makeMove, resetGame} = useGame();
return (
<div className="bg-home min-h-screen bg-gradient-to-br from-slate-200 to-slate-300 flex items-center justify-center">
<div className="bg-white rounded-xl shadow-2xl px-10 py-4">
<h1 className="text-4xl font-bold text-center mb-4 bg-gradient-to-r from-orange-300 via-orange-500 to-red-700 bg-clip-text text-transparent">
Game Cờ Caro X-O
</h1>
<Board
board={board}
currentPlayer={currentPlayer}
winner={winner}
isDraw={isDraw}
loading={loading}
onCellClick={makeMove}
/>
<div className="mt-2 text-center flex gap-2 justify-center">
{!winner && (
<button
onClick={undoMove}
disabled={!canUndo}
className=" px-3 py-1 rounded-lg font-semibold bg-gradient-to-r from-orange-600 to-pink-500 text-white disabled:opacity-1 disabled:cursor-not-allowed hover:scale-105 transition"
>
Undo
</button>
)}
<button
onClick={resetGame}
className=" px-3 py-1 rounded-lg font-semibold bg-gradient-to-r from-emerald-400 to-teal-500 text-white disabled:opacity-40 disabled:cursor-not-allowed hover:scale-105 transition"
>
Chơi lại
</button>
</div>
</div>
</div>
);
};
Kết nối vào App (App.tsx)
import { GameContainer } from './components/container/GameContainer';
function App() {
return <GameContainer />;
}
export default App;Và khi run game ta sẽ được game như này

Lợi ích của kiến trúc này
- Tách biệt rõ ràng: logic tách khỏi UI
- Dễ test: test logic và UI độc lập
- Tái sử dụng: Presentational có thể dùng với logic khác
- Dễ bảo trì: sửa logic không ảnh hưởng UI và ngược lại
- Code dễ đọc: mỗi file có trách nhiệm rõ ràng