Reactで達成感が味わえるアプリ(v0)を作ってみた
TastleというToDoアプリをアレンジしたものをReactで個人開発した話です。
背景
-
自己紹介
大学院に行くのを辞めたが、進路が決まっていない非情報学科の22歳(卒業研究中です)。 -
動機
アイデアを形にするのが好きで、エンジニアという職種は、それが仕事の1つでもあると思い興味を持っています。そこで、調べていくうちにフロントエンドが特に面白そうだと思い、UdemyでReactの講座を受講しました。そして、学んだ知識をとりあえず形にすることにしました。
作ったもの
「タスクをバトル(試合)」と見立てて、タスクを完了したら勝ちとし、勝った試合ラベルの上にあるスコアが1ずつ加算されます。また、タスクを2つ以上設定すると試合数-1ラベルの上にあるスコアが1ずつ加算されるようになっています。
URL
リポジトリ
使用した技術・ディレクトリ構成
このセクションでは、アプリを作成するのに使用した技術やディレクトリ構成について共有させていただきます。
技術スタック
名前 | 役割 | バージョン |
---|---|---|
React | ライブラリ | 18.3.1 |
TypeScript | 静的型付け | 5.6.2 |
Tailwind CSS | スタイリング | 3.4.14 |
Vite | ビルド | 5.4.10 |
ESLint | 静的分析 | 9.13.0 |
Prettier | コード整形 | 3.3.3 |
技術の特徴と利用した理由
React
UIを構築するために利用しました。
TypeScript
データの定義やPropsなどに利用しました。
Tailwind CSS
デザインに利用しました。
Vite
開発サーバーの起動やファイルを編集してブラウザに反映する時間を速くしてくれるという認識です。
Reactと一緒に利用しました。
ESLint・Prettier
- ESLint (静的分析ツール)
コードのガイドラインであるという認識です。 - Prettier (コードフォーマッター)
コードが見やすくなるように整形するという認識です。
テストを実装する技術力がまだついていないこともあり、そのうえで少しでもエラーを減らす目的で利用しました。
ディレクトリ構成
ディレクトリ構成は以下のようになりました。主にcomponentsディレクトリ、hooksディレクトリ、アプリ全体のエントリーポイントであるTastle.tsxファイルでコードを作成しました。
ディレクトリ構成
└── Tastle/
├── public/
│ └── fabicon.webp
├── src/
│ ├── components/ #UI関連のコンポーネントを格納
│ │ ├── ScoreBoard.tsx
│ │ ├── ScoreItem.tsx
│ │ ├── TaskAddButton.tsx
│ │ ├── TaskDeleteButton.tsx
│ │ ├── TaskInput.tsx
│ │ ├── TaskItem.tsx
│ │ ├── TaskList.tsx
│ │ └── Title.tsx
│ ├── hooks/ #カスタムフックを格納
│ │ ├── useScore.tsx
│ │ ├── useTaskInput.tsx
│ │ └── useTaskList.tsx
│ ├── styles/ # スタイルを格納
│ │ └── globals.css
│ ├── types/ #型定義を格納
│ │ ├── score.ts
│ │ └── task.ts
│ ├── main.tsx #Viteのエントリーポイント
│ └── Tastle.tsx #アプリ全体のエントリーポイント
├── .gitignore
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── README.md
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vite-env.d.ts
└── vite.config.ts
設計(実装した機能)
このセクションでは、アプリに実装した機能についての説明をします。コードを見ていただき、良い部分と改善点についてフィードバックをいただけると非常に嬉しいです。ぜひ、コメントのほどよろしくお願いします。
入力・生成機能
この機能は、タスクを入力し、Enterキーor追加ボタンを押すとタスクデータが生成されるといったものです。具体的な動作として以下のようなルールを設定しました。
- ユーザがタスクを入力する。
- 入力内容が空の場合はタスクに追加しない。
- 追加後に入力エリアをリセットする。
実際のコードと説明
import { TaskInput } from "./components/TaskInput";
import { useTaskList } from "./hooks/useTaskList";
export const Tastle = () => {
const { addTask } = useTaskList();
const handleAddTask = (title: string) => {
addTask(title);
};
return (
<main className="flex flex-col items-center p-8 bg-blue-50 min-h-screen">
<TaskInput addTask={handleAddTask} />
</main>
);
};
import { FC } from "react";
import { useTaskInput } from "../hooks/useTaskInput";
import { TaskAddButton } from "./TaskAddButton";
type Props = {
addTask: (title: string) => void;
};
export const TaskInput: FC<Props> = ({ addTask }) => {
const { taskInput, handleInputChange, handleSubmit } = useTaskInput(addTask);
return (
<>
<section className="text-center mb-10 max-w-2xl w-full mx-auto">
<form className="space-x-4" onSubmit={handleSubmit}>
<input
className="border-2 border-gray-300 rounded-md focus:border-orange-200 outline-none p-3 w-2/3 lg:w-4/5 placeholder-shown:border-gray-50"
type="text"
placeholder="試合内容を入力"
value={taskInput}
onChange={handleInputChange}
/>
<TaskAddButton />
</form>
</section>
</>
);
};
import { useState } from "react";
type useTaskInputReturn = {
taskInput: string;
handleInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleSubmit: (event: React.FormEvent) => void;
}
export const useTaskInput = (
addTask: (title: string) => void
): useTaskInputReturn => {
const [taskInput, setTaskInput] = useState("");
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTaskInput(event.target.value);
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (taskInput.trim()) {
addTask(taskInput);
setTaskInput("");
}
};
return {
taskInput,
handleInputChange,
handleSubmit,
};
};
入力データの管理
-
useTaskInput
というカスタムフックを定義し、useState
でタスク入力値データ(taskInput
)の状態を管理しています。handleOnChange
関数内にテキストボックスに入力された値がタスク入力値(taskInput
)になるようにsetTaskInput(event.target.value)
を設定しました。
フォーム送信時の処理
-
handleSubmit
関数にフォーム送信時の動作を定義し、入力値が空でない場合にタスクデータを更新する関数であるaddTask()
を呼び出すようにしています。そして、タスク追加後setTaskInpput("")
で入力値をリセットしています。
一覧表示機能
この機能は、先ほど紹介したタスクを入力し、生成されたタスクデータをリスト形式で一覧表示するといったものです。具体的な動作として以下のようなルールを定義しました。
- タスク入力値が空ではないときにタスクデータを更新。
- 更新されたタスクデータをリスト形式で表示。
実際のコードと説明
import { TaskList } from "./components/TaskList";
import { useTaskList } from "./hooks/useTaskList";
export const Tastle = () => {
const { tasks } = useTaskList();
return (
<main className="flex flex-col items-center p-8 bg-blue-50 min-h-screen">
<TaskList tasks={tasks}/>
</main>
);
};
import { FC } from "react";
import { TaskItem } from "./TaskItem";
import { Task } from "../types/task";
/* タスクリストを管理するコンポーネント */
type Props = {
tasks: Task[];
};
export const TaskList: FC<Props> = ({ tasks }) => {
return (
<section className="mb-10 max-w-xl w-full">
<ul className="w-4/5 md:w-5/6 lg:w-full mx-auto space-y-4">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
</section>
);
};
TaskItemコンポーネント
import { FC } from "react";
import { TaskDeleteButton } from "./TaskDeleteButton";
import { Task } from "../types/task";
type Props = {
task: Task;
};
export const TaskItem: FC<Props> = ({ task }) => {
return (
<li className="flex items-center justify-around p-2 md:p-3 rounded-md bg-orange-50">
<div className="flex items-center space-x-2">
<input
type="checkbox"
name={`${task.id}`}
id={`${task.id}`}
className="w-4 h-4"
/>
<label htmlFor={`${task.id}`} className="text-lg md:text-xl">
{task.title}
</label>
</div>
<TaskDeleteButton taskId={task.id} />
</li>
);
};
import { useEffect, useState } from "react";
import { Task } from "../types/task";
type useTaskListReturn = {
tasks: Task[];
addTask: (title: string) => void;
};
export const useTaskList = (): useTaskListReturn => {
const [tasks, setTasks] = useState<Task[]>(() => {
const savedTasks = localStorage.getItem("tasks");
return savedTasks ? JSON.parse(savedTasks) : [];
});
useEffect(() => {
localStorage.setItem("tasks", JSON.stringify(tasks))
}, [tasks])
const addTask = (title: string) => {
if (!title) return;
const newTask: Task = {
id: tasks.length > 0 ? Math.max(...tasks.map((task) => task.id)) + 1 : 1,
title,
completed: false,
};
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
};
return { tasks, addTask };
};
タスクデータの更新
-
addTask
関数では、新しいタスクタイトルを引数として受け取り、新しいタスクデータnewTask
変数に格納し、setTasks
を用いてtasks
を更新しています。
リスト表示
-
Tastle
コンポーネントから渡されるタスクデータ(tasks
)をTaskList
コンポーネントで受け取り、map
メソッドを使用してタスクを1つずつ表示するようにしています。
削除機能
この機能は、タスクリストととして表示されているタスクを削除できるようにしたものです。
具体的な動作としては、タスクは1つずつ削除するというルールを設定しました。
実際のコードと説明
import { TaskList } from "./components/TaskList";
import { useTaskList } from "./hooks/useTaskList";
export const Tastle = () => {
const { tasks, deleteTask } = useTaskList();
const handleDeleteTask = (taskId: number) => {
deleteTask(taskId);
};
return (
<main className="flex flex-col items-center p-8 bg-blue-50 min-h-screen">
<TaskList tasks={tasks} deleteTask={handleDeleteTask}/>
</main>
);
};
import { FC } from "react";
import { TaskItem } from "./TaskItem";
import { Task } from "../types/task";
type Props = {
tasks: Task[];
deleteTask: (taskId: number) => void;
};
export const TaskList: FC<Props> = ({ tasks, deleteTask,}) => {
return (
<section className="mb-10 max-w-xl w-full">
<ul className="w-4/5 md:w-5/6 lg:w-full mx-auto space-y-4">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onDelete={deleteTask}
/>
))}
</ul>
</section>
);
};
import { FC } from "react";
import { TaskDeleteButton } from "./TaskDeleteButton";
import { Task } from "../types/task";
type Props = {
task: Task;
onDelete: (taskId: number) => void;
};
export const TaskItem: FC<Props> = ({ task, onDelete }) => {
return (
<li className="flex items-center justify-around p-2 md:p-3 rounded-md bg-orange-50">
<div className="flex items-center space-x-2">
<input
type="checkbox"
name={`${task.id}`}
id={`${task.id}`}
className="w-4 h-4"
/>
<label htmlFor={`${task.id}`} className="text-lg md:text-xl">
{task.title}
</label>
</div>
<TaskDeleteButton taskId={task.id} onDelete={onDelete} />
</li>
);
};
import { useEffect, useState } from "react";
import { Task } from "../types/task";
type useTaskListReturn = {
tasks: Task[];
deleteTask: (taskId: number) => void;
};
export const useTaskList = (): useTaskListReturn => {
const [tasks, setTasks] = useState<Task[]>(() => {
const savedTasks = localStorage.getItem("tasks");
return savedTasks ? JSON.parse(savedTasks) : [];
});
useEffect(() => {
localStorage.setItem("tasks", JSON.stringify(tasks))
}, [tasks])
// タスクidを受け取り、idと合うタスクデータを削除する関数
const deleteTask = (taskId: number) => {
setTasks(tasks.filter((task) => task.id !== taskId));
};
return { tasks, deleteTask };
};
削除時の処理
-
deleteTask
関数では、削除対象のタスクのid
を引数として受け取り、setTasks
を用いてtasks
を更新します。このとき、filter
メソッドを使用して受け取ったid
に一致するタスクを除いた新しいタスクデータを生成し、tasks
として保存しました。
スコア更新機能
この機能は、ユーザがタスクを追加、削除、完了状態を切り替えた際に、関連するスコア(勝った試合ラベルもしくは試合数-1ラベル)を更新することを目的としています。
実際のコードと詳細
src/Tastle.tsx
import { TaskList } from "./components/TaskList";
import { useTaskList } from "./hooks/useTaskList";
import { ScoreBoard } from "./components/ScoreBoard";
import { useScore } from "./hooks/useScore";
export const Tastle = () => {
const { tasks, addTask, deleteTask toggleTaskCompletion } = useTaskList();
const { scores, updateScore } = useScore();
const handleAddTask = (title: string) => {
addTask(title);
const updatedTasks = [...tasks, { id: Date.now(), completed: false }];
updateScore("試合数-1", updatedTasks);
};
const handleDeleteTask = (taskId: number) => {
deleteTask(taskId);
const updatedTasks = tasks.filter((task) => task.id !== taskId);
updateScore("試合数-1", updatedTasks);
updateScore("勝った試合", updatedTasks, true, false);
};
const handleToggleTask = (taskId: number) => {
const completed = toggleTaskCompletion(taskId);
if (completed !== undefined) {
updateScore("勝った試合", tasks, !completed, completed);
}
};
return (
<main className="flex flex-col items-center p-8 bg-blue-50 min-h-screen">
<Title />
<!-- <TaskInput addTask={handleAddTask} /> -->
<TaskList
tasks={tasks}
deleteTask={handleDeleteTask}
toggleTaskCompletion={handleToggleTask}
/>
<ScoreBoard scores={scores} />
</main>
);
};
useTaskList.tsx
import { useEffect, useState } from "react";
import { Task } from "../types/task";
type useTaskListReturn = {
tasks: Task[];
addTask: (title: string) => void;
deleteTask: (taskId: number) => void;
toggleTaskCompletion: (taskId: number) => boolean | undefined;
};
export const useTaskList = (): useTaskListReturn => {
const [tasks, setTasks] = useState<Task[]>(() => {
const savedTasks = localStorage.getItem("tasks");
return savedTasks ? JSON.parse(savedTasks) : [];
});
useEffect(() => {
localStorage.setItem("tasks", JSON.stringify(tasks))
}, [tasks])
<!-- const addTask = (title: string) => {
if (!title) return;
const newTask: Task = {
id: tasks.length > 0 ? Math.max(...tasks.map((task) => task.id)) + 1 : 1,
title,
completed: false,
};
const updatedTasks = [...tasks, newTask];
setTasks(updatedTasks);
}; -->
<!-- const deleteTask = (taskId: number) => {
setTasks(tasks.filter((task) => task.id !== taskId));
}; -->
// タスクidを受け取り、idと合うタスクの完了状態を切り替える関数
const toggleTaskCompletion = (taskId: number): boolean | undefined => {
const taskIndex = tasks.findIndex((task) => task.id === taskId);
if (taskIndex !== -1) {
const updatedTasks = [...tasks];
updatedTasks[taskIndex].completed = !updatedTasks[taskIndex].completed;
setTasks(updatedTasks);
return updatedTasks[taskIndex].completed; // 新しい完了状態を返す
}
return undefined;
};
return { tasks, addTask, deleteTask, toggleTaskCompletion };
};
import { FC } from "react";
import { ScoreItem } from "./ScoreItem";
import { Score } from "../types/score";
type Props = {
scores: Score[];
};
export const ScoreBoard: FC<Props> = ({ scores }) => {
return (
<section className="mb-8 max-w-xl md:max-w-2xl w-full">
<h2 className="text-xl md:text-2xl text-center font-semibold text-gray-700 mb-7">
・ 現在のスコア
</h2>
<div className="flex flex-col md:flex-row md:space-x-6">
{scores.map((scoreItem, index) => (
<ScoreItem key={index} {...scoreItem} />
))}
</div>
</section>
);
};
import { FC } from "react";
import { Score } from "../types/score";
type Props = Score
export const ScoreItem: FC<Props> = ({score, label, color}) => {
return (
<>
<div
className={`${color} p-4 rounded-md text-center w-3/4 md:w-1/2 space-y-3 mx-auto mb-3 md:mb-0`}
>
<span className="text-5xl md:text-6xl font-bold font-mono">
{score}
</span>
<p
className={`font-semibold ${color === "bg-green-100" ? "text-green-500" : "text-red-500"}`}
>
{label}
</p>
</div>
</>
);
};
import { useEffect, useState } from "react";
import { Score } from "../types/score";
export const useScore = () => {
const [scores, setScores] = useState<Score[]>(() => {
const savedScores = localStorage.getItem("scores");
return savedScores
? JSON.parse(savedScores)
: [
{
score: 0,
label: "勝った試合",
color: "bg-green-100",
},
{
score: 0,
label: "試合数-1",
color: "bg-red-200",
},
];
});
useEffect(() => {
localStorage.setItem("scores", JSON.stringify(scores));
}, [scores]);
// スコア更新ロジック
const updateScore = (
label: string,
tasks: { id: number; completed: boolean }[],
previousCompleted?: boolean,
currentCompleted?: boolean
) => {
setScores((prevScores) =>
prevScores.map((score) => {
if (score.label === label) {
if (label === "勝った試合") {
let newScore = score.score;
// 勝った試合: 完了 +1、未完了 -1
if (previousCompleted === false && currentCompleted === true) {
newScore = score.score + 1;
}
if (previousCompleted === true && currentCompleted === false) {
newScore = score.score - 1;
}
return { ...score, score: Math.max(0, newScore) };
}
if (label === "試合数-1") {
// 試合数-1: タスク数 - 1 (ただし最低値は 0)
const taskCount = tasks.length;
const newScore = taskCount <= 1 ? 0 : taskCount - 1;
return { ...score, score: newScore };
}
}
return score;
})
);
};
return { scores, updateScore };
};
タスクの完了状態を切り替える関数の挙動(useTaskList
フック内)
const toggleTaskCompletion = (taskId: number): boolean | undefined => {
const taskIndex = tasks.findIndex((task) => task.id === taskId);
if (taskIndex !== -1) {
const updatedTasks = [...tasks];
updatedTasks[taskIndex].completed = !updatedTasks[taskIndex].completed;
setTasks(updatedTasks);
return updatedTasks[taskIndex].completed; // 新しい完了状態を返す
}
return undefined;
};
- タスクID(
taskId
)を受け取る。 -
tasks
配列内で該当するタスクを探す。 - 該当タスクの
completed
プロパティの真偽値を反転させる(true
→false
またはその逆) -
setTasks
を使って状態を更新。 - 新しい完了状態を返す(
true
またはfalse
)。
スコア更新関数の挙動(useScore
フック内)
if (label === "勝った試合") {
if (previousCompleted === false && currentCompleted === true) {
newScore = score.score + 1;
}
if (previousCompleted === true && currentCompleted === false) {
newScore = score.score - 1;
}
return { ...score, score: Math.max(0, newScore) };
}
if (label === "試合数-1") {
const taskCount = tasks.length;
const newScore = taskCount <= 1 ? 0 : taskCount - 1;
return { ...score, score: newScore };
}
-
label
で更新するスコアの種類(勝った試合or試合数-1)を指定する。 - 勝った試合のスコアが更新されるのは、タスクの完了状態が
false
→true
のとき。よって、タスクの完了状態が切り替わる前後の状態を受け取る(previousCompleted
/currentCompleted
)。 - そして、完了状態が
false
→true
ならスコア+1、true
→false
ならスコア-1。 - 試合数-1のスコアが更新されるのは、タスクの設定数(
tasks.length
)が2以上になったとき。
苦労したこと・工夫したこと
・プロジェクトの設定について
ESLintの設定を行おうとしたが、デフォルトの設定ファイルがeslintrc.jsからeslint.config.jsに代わっており、参考文献は前者が多かったので、戸惑いました。どのような設定を行うべきかわからなかったのですが、eslint-plugin-reactというreactを記述する時の推奨コード設定プラグインを設定しました。
・実装について
1つの関数コンポーネントにUI表示部分と関連したロジック部分のコードが混在していたので、カスタムフックを使用することで、責務を分離させました。
コンポーネント間でstateを共有する必要がある(タスクとスコア)のですが、それぞれのstateが独立したいため、思い通りの挙動になりませんでした。そこで、親コンポーネントであるTastle
コンポーネントでstate(タスクとスコア)を一元管理して、propsとしてstateを渡すことで解決しました。
最後に
このセクションでは、現状の振り返りと課題、そして今後の展望についてお伝えします。
達成感を感じることができるのか?
正直なところ、現時点では私自身も「達成感」を十分に感じれないなと感じています。その理由として、以下のような課題があると考えています。
- タスクをこなすとスコアが変動するだけで、ゲームとしての面白さに欠けている。
- デザインが洗練されていないため、魅力がない。
- 他のツールでタスク管理をしている人には利用されない(私はNotionをタスク管理に使っています)
これらの課題を克服するために、以下の改善案を検討しています。
- タスクの難易度に応じてスコアの上昇値を調整し、達成感を高める仕組みを取り入れる。
- 野球ルールを応用したゲーム要素を取り入れる。
- UI/UXデザインを学び、より使いやすく魅力的な画面を目指す。
技術的な課題
・データの保存先がブラウザのlocalStorage
なので、大量のデータを扱うには不十分であるため(成長したときに支えられなくなる)、サーバーでのデータ管理やクラウドストレージの利用も検討しなければいけないと思います。
・状態管理ライブラリや、ルーティング、バックエンドの連携、テスト導入などのより実務で使われる技術についても理解して導入できるようにしたいです。
一言
最後まで、読んだいただきありがとうございました。些細なことでも意見があればコメントなどに書き込んでくださるとありがたいです。よろしくお願いします。
はじめてのアプリを作ってみて、正直大変でしたが、率直に楽しかったです(次に作りたいアプリのアイデアも生まれました)。
Discussion