概要
前回の記事ではバックエンド開発ついて書きましたので今回はフロントエンド開発について記していきたいと思います。
環境
- Next.js
- TypeScript
- Styled-components(cssスタイリング)
プロジェクトの作成
npx create-next-app@latest baselog-frontend --typescript
styled-componentsの導入
npm install styled-components
ただインストールするだけだとcssが反映されない場合があったので以下の記事を参考にしました。
参考
ディレクトリ構成
├── node_modules
├── public
├── src
│ └── app
│ │ ├── addgame
│ │ │ └── page.tsx
│ │ ├── games
│ │ │ ├── detail
│ │ │ └── page.tsx
│ │ │ ├── edit
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── styles
│ │ └── GlobalStyles.ts
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ ├── registry.tsx
│
├── types
│ └── types.ts
└── usecases
└── useAuth.ts
ディレクトリ構成は一旦このような感じです。
実装
試合結果一覧ページの実装
まずは試合結果一覧ページの実装を進めていきます。
'use client';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import api from '@lib/api';
import type { Game } from 'types/types';
export default function Gamepage() {
const [games, setGames] = useState<Game[]>([]);
useEffect(() => {
const fetchGames = async () => {
try {
const response = await api.get<Game[]>('/games');
setGames(response.data);
}
catch (error) {
console.error('Error fetching game data:', error);
}
};
fetchGames();
}, []);
return (
<Container>
<Title>試合結果一覧</Title>
<Table>
<thead>
<TableRowHeader>
<Th>Date</Th>
<Th>Home</Th>
<Th>Away</Th>
<Th>Score</Th>
</TableRowHeader>
</thead>
<tbody>
{games.map((game) => (
<TableRow key={game.id}>
<Td>
{new Date(game.date).toLocaleDateString()}
</Td>
<Td>{game.home_team}</Td>
<Td>{game.away_team}</Td>
<Td>{`${game.home_total_score} - ${game.away_total_score}`}</Td>
</TableRow>
))}
</tbody>
</Table>
</Container>
);
}
//以下スタイルが続く
解説
冒頭のこの部分ではuseStateフックを利用して、状態変数gamesと更新関数setGamesを定義しています。初期値として[]が定義されており、Game[]でTypeScriptの型指定を行っています。Gameは型アノテーションでgames配列の各要素がGame型のオブジェクトであることを明示しています。setGamesは状態を更新するための関数でsetGamesを呼び出すとgamesが更新されコンポーネントが再レンダリングされます。
const [games, setGames] = useState<Game[]>([]);
Game型の定義
export interface Game {
id: number;
date: string;
home_team: string;
away_team: string;
home_total_score: number;
away_total_score: number;
home_total_hits: number;
away_total_hits: number;
home_total_errors: number;
away_total_errors: number;
home_inning_1: number;
home_inning_2: number;
//以下略
}
続いて以下の部分の解説をします。useEffectに渡された関数はレンダーの結果が画面に反映された後に動作します。副作用の処理(DOMの書き換え、変数代入、API通信などUI構築以外の処理)を関数コンポーネントで扱えます。
参考:https://qiita.com/seira/items/e62890f11e91f6b9653f
また、依存配列を空にすることで一度だけ実行するというように指定しています。fetchgamesはAPIからデータを取得する非同期関数として宣言されています。また、try-catch文でエラーハンドリングを行っています。await api.getでレスポンスの型指定を行っています。awaitでリクエストが完了してから次の処理に進むようになっています。setGames(response.data)の部分ではresponse.dataにAPIから取得したGame型の配列が格納されていて、それを状態関数であるgamesに保存するためにsetGamesという更新関数を呼び出しています。response.data と指定するのは、api.get()が返すresponseオブジェクトの中に実際のデータがdataプロパティとして格納されているためです。
useEffect(() => {
const fetchGames = async () => {
try {
const response = await api.get<Game[]>('/games');
setGames(response.data);
} catch (error) {
console.error('Error fetching game data:', error);
}
};
fetchGames();
}, []);
参考記事
試合結果追加ページ
続いて、試合結果を追加するページを作成していきます。
まずは試合結果一覧画面に試合結果を追加に遷移するためのボタンを設置しておきます。
<Link href="/addgame">
<GameAddButton>試合を追加</GameAddButton>
</Link>
そしてaddgame/page.tsxを記述していきます。
'use client';
import React, { useState } from 'react';
import styled from 'styled-components';
import api from '@lib/api';
import { useRouter } from 'next/navigation';
import type { GameData } from 'types/types';
//試合を追加するにあたって必要な項目の状態管理
export default function AddGamePage() {
const [date, setDate] = useState('');
const [homeTeam, setHomeTeam] = useState('');
const [awayTeam, setAwayTeam] = useState('');
const [homeTotalScore, setHomeTotalScore] = useState(0);
const [awayTotalScore, setAwayTotalScore] = useState(0);
const [homeTotalHits, setHomeTotalHits] = useState(0);
const [awayTotalHits, setAwayTotalHits] = useState(0);
const [homeTotalErrors, setHomeTotalErrors] = useState(0);
const [awayTotalErrors, setAwayTotalErrors] = useState(0);
const [innings, setInnings] = useState(
Array.from({length: 9}, () => ({home: 0, away: 0}))
);
const router = useRouter();
const handleSave = async() => {
try {
const data: GameData = {
date,
home_team: homeTeam,
away_team: awayteam,
home_total_score: homeTotalScore,
away_total_score: awayTotalScore,
home_total_hits: homeTotalHits,
away_total_hits: awayTotalHits,
home_total_errors: homeTotalErrors,
away_total_errors: awayTotalErrors,
innings.forEach((inning, index) => {
data[`home_inning_${index + 1}`] = inning.home;
data[`away_inning_${index + 1}`] = inning.away;
});
//dataオブジェクトをPOSTリクエストとしてAPIエンドポイント/gamesに送信し/gamesページにリダイレクトする
await api.post('/games', data);
router.push('/games');
} catch(error) {
console.error('Error has occured:', error);
}
};
//reduce()で配列の合計値を出す
const calculateTotalscores = () => {
setHomeTotalScore(
innings.reduce((sum, inning) => sum + inning.home, 0)
);
setAwayTotalScore(
innings.reduce((sum, inning) => sum + inning.away, 0)
);
};
return (
<Container>
<Title>試合情報の追加</Title>
<Form>
<Label>
Date:
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)} //eはイベントを引数として受け取る。e.targetはイベントが発生したHTML要素を指す(ここではInput)。valueは現在の値。
/>
</Label>
<Label>
Away Team:
<Input
type="text"
value={awayTeam}
onChange={(e) => setAwayTeam(e.target.value)}
/>
</Label>
<Label>
Home Team:
<Input
type="text"
value={homeTeam}
onChange={(e) => setHomeTeam(e.target.value)}
/>
</Label>
<Scoreboard>
<thead>
<tr>
<th>Inning</th>
//スプレッド構文を利用して配列を生成。iが0から8までの値を取るため+1する。
{[...Array(9)].map((_, i) => (
<th key={i}>{i + 1}</th>
))}
<th>R</th>
<th>H</th>
<th>E</th>
</tr>
</thead>
<tbody>
<tr>
<Td>Away</Td>
{innings.map((inning, index) => (
//Reactのキー属性
<Td key={index}>
<InningInput
type="number"
value={inning.away}
//ユーザーが数値を入力するたびに発生
onChange={(e) =>
setInnings((prev) => {
const updatedInnings = [...prev];
//入力値を0以上に制限
updatedInnings[index].away =
Math.max(
0,
Number(e.target.value)
);
calculateTotalscores();
return updatedInnings;
})
}
/>
</Td>
))}
<Td>{awayTotalScore}</Td>
<Td>
<InningInput
type="number"
value={awayTotalHits}
onChange={(e) =>
setAwayTotalHits(
Math.max(0, Number(e.target.value))
)
}
/>
</Td>
<Td>
<InningInput
type="number"
value={awayTotalErrors}
onChange={(e) =>
setAwayTotalErrors(
Math.max(0, Number(e.target.value))
)
}
/>
</Td>
</tr>
<tr>
<Td>Home</Td>
{innings.map((inning, index) => (
<Td key={index}>
<InningInput
type="number"
value={inning.home}
onChange={(e) =>
setInnings((prev) => {
const updatedInnings = [...prev];
updatedInnings[index].home =
Math.max(
0,
Number(e.target.value)
);
calculateTotalscores();
return updatedInnings;
})
}
/>
</Td>
))}
<Td>{homeTotalScore}</Td>
<Td>
<InningInput
type="number"
value={homeTotalHits}
onChange={(e) =>
setHomeTotalHits(
Math.max(0, Number(e.target.value))
)
}
/>
</Td>
<Td>
<InningInput
type="number"
value={homeTotalErrors}
onChange={(e) =>
setHomeTotalErrors(
Math.max(0, Number(e.target.value))
)
}
/>
</Td>
</tr>
</tbody>
</Scoreboard>
<Button onClick={handleSave}>保存</Button>
</Form>
</Container>
);
}
//以下スタイルが続く
今回はここまでにしたいと思います。
次回の記事で、試合結果の詳細ページ、編集ページ、削除機能を実装していきます。
参考文献