🗂

Reactで達成感が味わえるアプリ(v0)を作ってみた

2024/12/08に公開

TastleというToDoアプリをアレンジしたものをReactで個人開発した話です。

背景

  • 自己紹介
    大学院に行くのを辞めたが、進路が決まっていない非情報学科の22歳(卒業研究中です)。

  • 動機
    アイデアを形にするのが好きで、エンジニアという職種は、それが仕事の1つでもあると思い興味を持っています。そこで、調べていくうちにフロントエンドが特に面白そうだと思い、UdemyでReactの講座を受講しました。そして、学んだ知識をとりあえず形にすることにしました。

作ったもの



タスクをバトル(試合)」と見立てて、タスクを完了したら勝ちとし、勝った試合ラベルの上にあるスコアが1ずつ加算されます。また、タスクを2つ以上設定すると試合数-1ラベルの上にあるスコアが1ずつ加算されるようになっています。

URL

https://tastle.vercel.app

リポジトリ

https://github.com/rintarotajima/Tastle

使用した技術・ディレクトリ構成

このセクションでは、アプリを作成するのに使用した技術やディレクトリ構成について共有させていただきます。

技術スタック

名前 役割 バージョン
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
https://react.dev/
JavaScriptのライブラリ。コンポーネント(部品)を組み合わせてUI(画面)を構築する、仮想DOMの概念を利用することで、画面のレンダリングを最適化するなどの特徴があるという認識です。
UIを構築するために利用しました。

TypeScript
https://www.typescriptlang.org/
JavaScriptに型を付けることができるスーパーセット言語。スーパーセットとは、元の言語の特徴を受け継ぎながら、より安全で質の高い機能を実現できるという認識です。
データの定義やPropsなどに利用しました。

Tailwind CSS
https://tailwindcss.com/
ユーティリティーファーストクラスを軸にしたCSSフレームワーク。フレームワーク側が既にサイズを定義しているため、自分がサイズを考えなくてもよく、カスタマイズもしやすいという認識です。
デザインに利用しました。

Vite
https://vite.dev/
モジュールバンドラー(ビルドツール)。
開発サーバーの起動やファイルを編集してブラウザに反映する時間を速くしてくれるという認識です。
Reactと一緒に利用しました。

ESLint・Prettier
https://eslint.org/
https://prettier.io/

  • 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追加ボタンを押すとタスクデータが生成されるといったものです。具体的な動作として以下のようなルールを設定しました。

  1. ユーザがタスクを入力する。
  2. 入力内容が空の場合はタスクに追加しない。
  3. 追加後に入力エリアをリセットする。

実際のコードと説明

src/Tastle.tsx
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>
  );
};
components/TaskInput.tsx
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>
    </>
  );
};

hooks/useTaskInput.tsx
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("")で入力値をリセットしています。

一覧表示機能

この機能は、先ほど紹介したタスクを入力し、生成されたタスクデータをリスト形式で一覧表示するといったものです。具体的な動作として以下のようなルールを定義しました。

  1. タスク入力値が空ではないときにタスクデータを更新。
  2. 更新されたタスクデータをリスト形式で表示。

実際のコードと説明

src/Tastle.tsx
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>
  );
};
components/TaskList.tsx
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コンポーネント
components/TaskItem.tsx
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>
  );
};
hooks/useTaskList.tsx
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つずつ削除するというルールを設定しました。

実際のコードと説明

src/Tastle.tsx
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>
  );
};
components/TaskList.tsx
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>
  );
};
components/TaskItem.tsx
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>
  );
};
hooks/useTaskList.tsx
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
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
hooks/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 };
};
components/ScoreBoard.tsx
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>
  );
};
components/ScoreItem.tsx
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>
    </>
  );
};
hooks/useScore.tsx
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;
};
  1. タスクID(taskId)を受け取る。
  2. tasks配列内で該当するタスクを探す。
  3. 該当タスクのcompletedプロパティの真偽値を反転させる(truefalseまたはその逆)
  4. setTasksを使って状態を更新。
  5. 新しい完了状態を返す(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 };
}

  1. labelで更新するスコアの種類(勝った試合or試合数-1)を指定する。
  2. 勝った試合のスコアが更新されるのは、タスクの完了状態がfalsetrueのとき。よって、タスクの完了状態が切り替わる前後の状態を受け取る(previousCompleted/currentCompleted)。
  3. そして、完了状態がfalsetrueならスコア+1、truefalseならスコア-1。
  4. 試合数-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