2025-3I プログラミング3 第05回 講義資料

2025年10月30日(木)5・6時限

1 連絡

1.1 ここまでの流れ

本科目は学修単位科目です。1回の授業あたり、皆さんが「4時間相当の授業時間外学習」をすることを前提 としたボリュームと展開速度となっています。1週間の間隔をあけての90分だけの取り組みで理解・習得できる内容ではないので注意してください。

初回授業から現在までの授業の流れ

  1. モダンTypeScript基礎学習のための環境構築
  2. TypeScript基礎学習
  3. Reactを使ったTodoアプリのための環境構築
  4. Todoアプリ開発(Reactフロントエンド開発)のチュートリアル ← 前回と今回
  5.  Todoアプリのカスタマイズや作り込み 👉 課題1

2 新規タスクの追加

現在のTodoリストに「新しいタスク (Todo) を追加する処理」を実装します。

src/App.tsx が、次のようなコードになっていることを前提に解説します。もし、src/App.tsx をカスタマイズしている場合は、一旦、別名に変更するなど退避させて、以下のコードを使用して実験してください。

import { useState } from "react";
import type { Todo } from "./types";
import { initTodos } from "./initTodos";
import WelcomeMessage from "./WelcomeMessage";
import TodoList from "./TodoList";

const App = () => {
  const [todos, setTodos] = useState<Todo[]>(initTodos);
  const uncompletedCount = todos.filter((todo: Todo) => !todo.isDone).length; // 未完了タスクの数え上げ

  return (
    <div className="mx-4 mt-10 max-w-2xl md:mx-auto">
      <h1 className="mb-4 text-2xl font-bold">TodoApp</h1>
      <div className="mb-4">
        <WelcomeMessage
          name="寝屋川タヌキ"
          uncompletedCount={uncompletedCount}
        />
      </div>
      <TodoList todos={todos} />
    </div>
  );
};

export default App;

いまから、このページの下部に「追加」というボタンを追加していきます。そして、ユーザーがそのボタンを押下したときに…

  1. 新しいTodoオブジェクト newTodo を生成する。
  2. 現在のTodo配列 todos の末尾に newTodo を追加した新しいTodo配列 newTodos を生成する。
  3. 状態を変更する関数 setTodosnewTodos を与える (React的な作法に則って状態を更新)
    • setTodos は上記プログラムの 第08行目useState によって生成されたものです。

以上の手順でTodoリストを更新し、適切にウェブ画面が再レンダリング されるように src/App.tsx をアップデートしていきます。具体的には、次のように実装します。

import { useState } from "react";
import type { Todo } from "./types";
import { initTodos } from "./initTodos";
import WelcomeMessage from "./WelcomeMessage";
import TodoList from "./TodoList";
import { v4 as uuid } from "uuid"; // ◀◀ 追加

const App = () => {
  const [todos, setTodos] = useState<Todo[]>(initTodos);
  const uncompletedCount = todos.filter((todo: Todo) => !todo.isDone).length;

  // 追加: addNewTodo関数の実装
  const addNewTodo = () => {
    const newTodo: Todo = {
      id: uuid(),
      name: "新しいタスク",
      isDone: false,
      priority: 3,
      deadline: new Date(2024, 10, 13),
    };
    // スプレッド構文を使って、末尾に新タスクを追加した配列を作成
    const updatedTodos = [...todos, newTodo];
    setTodos(updatedTodos); // 作成した配列をtodosにセット
  };

  return (
    <div className="mx-4 mt-10 max-w-2xl md:mx-auto">
      <h1 className="mb-4 text-2xl font-bold">TodoApp</h1>
      <div className="mb-4">
        <WelcomeMessage
          name="寝屋川タヌキ"
          uncompletedCount={uncompletedCount}
        />
      </div>
      <TodoList todos={todos} />

      {/* タスク追加関連のUI実装 ここから... */}
      <div className="mt-5 space-y-2 rounded-md border p-3">
        <h2 className="text-lg font-bold">新しいタスクの追加</h2>
        <button
          type="button"
          onClick={addNewTodo} // ボタンを押下したときの処理
          className="rounded-md bg-indigo-500 px-3 py-1 font-bold text-white hover:bg-indigo-600"
        >
          追加
        </button>
      </div>
      {/* ...ここまで */}
    </div>
  );
};

export default App;

上記のプログラムでは、新しいタスクを追加するための「addNewTodo」という関数第12~24行目 にアロー関数形式で実装しています。

JavaScript (TypeScript) では、クロージャという仕組みによって…

… となっています。ここで「関数 addNewTodo」は「関数App」の内部で定義されているため、第22行目 のように addNewTodo関数の外側にある変数 (=第09行目 で定義されている変数) todos の参照が可能になっています。なお、Python にもクロージャの仕組みがありますが、C言語ではクロージャは利用できません (そもそもC言語では、関数内部に関数を定義することができません)。

UIに関連するものとしては 第38行目第47行目 にJSX要素を追加しています。特に 第42行目onClick={addNewTodo} によって、ボタンがクリックしたときに 第13行目~ に定義した「addNewTodo関数」が呼び出されるようになっている点に注目してください。なお、ここで onClick={addNewTodo()} と書いてしまう間違いが多いので注意してください。もし、両者の違いが分からないときは生成AIを使って解決してください。

(プロンプト例)

TypeScript環境のReactで「Todoアプリ」を開発しています。ボタンを押下したとき に addNewTodo という関数 (引数なし) を実行したいです。このようなときは、ボタンの属性を onClick={addNewTodo} のように設定すると習いました。なぜ、onClick={addNewTodo()} のように書かないのですか?

(プロンプト例)

TypeScriptに関する質問です。「クロージャ」とはなんですか?関数 f1_outer のなかに、関数 f2_innter を定義したとき、その内部処理で、外部 (f1_outer) の変数を参照できると聞いたのですが、意味不明です。あと、それにメリットはあるのですか。

2.1 動作確認

src/App.tsx を更新・保存して、開発モード (npm run dev) でアプリを実行してください。以下の図ように「追加」ボタンを押下するたびに、リスト末尾に「新しいタスク」が追加されていくことが確認できます。

img

※ 実際の実行画面 (=Todoリストの外観) は「TodoListコンポーネント (src/TodoList.tsx)」の実装によって変化します。前回講義の宿題として、TodoListコンポーネントをカスタマイズしてもらっているので、実行画面の外観は各々で異なるはずです。

※ Todoリストの初期値 (src/initTodos.ts) は こちらを使用しています。

注意

前回講義の演習の一環で、TodoList.tsx の内部で次のように todos並び替え処理 を実装している場合は、、、

// 優先度順に todos を並び替え
const todos = [...props.todos].sort((a, b) => a.priority - b.priority);

一時的に、並び替えを無効にしておいてください (initTodosの順番に表示されるようにしておいてください)。

// const todos = [...props.todos].sort((a, b) => a.priority - b.priority);
const todos = props.todos;

2.1.1 演習 3分

確認後は、元に戻しておいてください (=新規タスクが「末尾」に追加されるように戻しておいてください)。

2.2 NG: Todos配列に対するミュータブルな操作

理解を深めるためにミュータブルな配列操作を使って todos を更新するとどうなるか? について実験・確認しておきます。ミュータブルな操作を使って addNewTodo を実装した例を以下に示します。特に 第09行目 に着目してください。

const addNewTodo = () => {
  const newTodo: Todo = {
    id: uuid(),
    name: "新しいタスク",
    isDone: false,
    priority: 3,
    deadline: new Date(2024, 10, 13),
  };
  todos.push(newTodo); // ミュータブルな配列操作 => ReactではNG
  setTodos(todos);
  console.log(JSON.stringify(todos, null, 2)); // todosの内容を表示
};

上記のように addNewTodo を実装すると、「追加」のボタンを押下しても ウェブ画面上にはタスクが追加されない ことが確認できます。ただし、第11行目 のコンソール出力を デベロッパーツール ([F12] で起動) から確認すると、以下のように 内部的には todos の末尾に新しいタスクが正常に追加されていることが確認できます。

img

つまり、push メソッドなどの ミュータブルな配列操作 で変更した配列を setTodos に与えても React は画面の再レンダリングはしない ということが確認できました。React は setTodos の引数に与えた オブジェクトの参照 (C言語で言えばポインタ、Pythonで言えばオブジェクトID) が、前回から変化しているときに 変更が生じたと判断して (=変更を検知して) ウェブ画面を再レンダリングする、という仕組みなっていることを覚えておいてください。

3 新規タスクの名前設定テキストボックスの追加

新規タスクの名前 (=Todoオブジェクトの name プロパティ) を設定するための「テキストボックス」を追加します。参考動画 (=1から簡単なTodoアプリを作ってReactの1歩を踏み出してみよう) のなかでは、テキストボックス関連の処理を 「useRef」という React Hook で実装していましたが、ここでは、あとから バリデーション などの処理を追加するために useState という仕組みを使って実装していきます。

img

なお、アプリ開発の文脈において「バリデーション (Validation)」とは データの書式や内容のチェック、例えば 文字数が規定範囲内に収まっているか、文字列が有効なメールアドレス形式になっているかどうか といったチェック処理を指します。

以下に、新規タスクの名前 (name) を設定するための「テキストボックス」を実装した例を示します。

import { useState } from "react";
import type { Todo } from "./types";
import { initTodos } from "./initTodos";
import WelcomeMessage from "./WelcomeMessage";
import TodoList from "./TodoList";
import { v4 as uuid } from "uuid";

const App = () => {
  const [todos, setTodos] = useState<Todo[]>(initTodos);
  const [newTodoName, setNewTodoName] = useState(""); // ◀◀ 追加
  const uncompletedCount = todos.filter((todo: Todo) => !todo.isDone).length;
  console.log(JSON.stringify(todos, null, 2));

  // ▼▼ 追加
  const updateNewTodoName = (e: React.ChangeEvent<HTMLInputElement>) => {
    // あとでバリデーションなどの処理をここに追加する
    setNewTodoName(e.target.value);
  };

  const addNewTodo = () => {
    const newTodo: Todo = {
      id: uuid(),
      name: newTodoName, // ◀◀ 編集
      isDone: false,
      priority: 3,
      deadline: new Date(2024, 10, 13),
    };
    const updatedTodos = [...todos, newTodo];
    setTodos(updatedTodos);
    setNewTodoName(""); // ◀◀ 追加
  };

  return (
    <div className="mx-4 mt-10 max-w-2xl md:mx-auto">
      <h1 className="mb-4 text-2xl font-bold">TodoApp</h1>
      <div className="mb-4">
        <WelcomeMessage
          name="寝屋川タヌキ"
          uncompletedCount={uncompletedCount}
        />
      </div>
      <TodoList todos={todos} />

      <div className="mt-5 space-y-2 rounded-md border p-3">
        <h2 className="text-lg font-bold">新しいタスクの追加</h2>

        {/* テキストボックスの実装 ここから... */}
        <div className="flex items-center space-x-2">
          <label className="font-bold" htmlFor="newTodoName">
            名前
          </label>
          <input
            id="newTodoName"
            type="text"
            value={newTodoName}
            onChange={updateNewTodoName}
            className="grow rounded-md border p-2"
            placeholder="2文字以上、32文字以内で入力してください"
          />
        </div>
        {/* ...ここまで */}

        <button
          type="button"
          onClick={addNewTodo}
          className="rounded-md bg-indigo-500 px-3 py-1 font-bold text-white hover:bg-indigo-600"
        >
          追加
        </button>
      </div>
    </div>
  );
};

export default App;

ウェブアプリにおいて「テキストボックス」や「チェックボックス」「ラジオボタン」など入力系UI (フォーム入力) には「<input> タグ」を利用します (参考: フォーム入力の種類)。そして、React開発においては、一般に <input> タグは useRef または useState を組み合わせて使用 します。

ここでは「新規タスクの名前」をテキストボックスから得たいので、第10行目 において useState から「変数 newTodoName」と「関数 setNewTodoName を生成しています (つまり newTodoName を「状態」として扱うように実装しています)。また 第10行目 では newTodoName の初期値を 空文字 "" に設定しています。

3.1 ユーザによるUI操作と状態更新の対応付け

第55行目 では、この newTodoName の内容がテキストボックスに反映されるように <input> タグの value属性value={newTodoName} のように設定しています。

ただし、これだけでは逆方向の値の反映がされません。具体的には ユーザによるテキストボックスの編集操作が、変数 newTodoName に対して 反映されません。これを反映するには、まず 第56行目 のように onChange={updateNewTodoName} とする必要があります。これにより、テキストボックスで文字列を編集するたびに第17行目 で定義している「updateNewTodoName関数」がコールされるようになります (呼び出されるようになります)。この際、コールされる関数には「React.ChangeEvent<HTMLInputElement> 型の引数」が渡されます。この引数には、型の名前が表すように HTML の Input要素 の変更イベントに関する各種情報 が格納されています。

第15行目 の「updateNewTodoName関数」では、それを e という仮引数で受け取って e.target.value から「変更された文字列」を取得して、第17行目setNewTodoName にセットしています。

3.1.1 演習

第57行目 のCSSクラスから grow を削除するとテキストボックスのスタイリングがどのように変化するか確認してください。

3.2 ラベルタグの設定

ここでの実装例では、テキストボックス (インプット要素) に対して ラベルタグ <label> を組み合わせて使用しています。ラベルタグの htmlFor属性 に対して「紐づけたいインプット要素の id」を設定すると (例えば、第49行目 のように htmlFor="newTodoName" のように設定すると) ラベルをクリックしたときに 対応するインプットがアクティブ になります。

<div className="flex items-center space-x-2">
  <label className="font-bold" htmlFor="newTodoName">
    名前
  </label>
  <input
    id="newTodoName"
    type="text"
    value={newTodoName}
    onChange={updateNewTodoName}
    className="grow rounded-md border p-2"
    placeholder="2文字以上、32文字以内で入力してください"
  />
</div>

些細な工夫ですが、確実に UI/UX の向上に繋がるので <input><label>セットで使用すること を意識するようにしてください。

img

4 優先度を設定するラジオボタンの追加

新規タスクの優先度 (priority) を設定するための「ラジオボタン」を実装する例を以下に示します。

img

ラジオボタンには、以下の 第74行目 のように属性を type="radio" のように設定した <input> タグを使用します。

import { useState } from "react";
import type { Todo } from "./types";
import { initTodos } from "./initTodos";
import WelcomeMessage from "./WelcomeMessage";
import TodoList from "./TodoList";
import { v4 as uuid } from "uuid";

const App = () => {
  const [todos, setTodos] = useState<Todo[]>(initTodos);
  const [newTodoName, setNewTodoName] = useState("");
  const [newTodoPriority, setNewTodoPriority] = useState(3); // ◀◀ 追加

  const uncompletedCount = todos.filter((todo: Todo) => !todo.isDone).length;

  const updateNewTodoName = (e: React.ChangeEvent<HTMLInputElement>) => {
    // あとでバリデーションなどの処理をここに追加する
    setNewTodoName(e.target.value);
  };

  // ▼▼ 追加
  const updateNewTodoPriority = (e: React.ChangeEvent<HTMLInputElement>) => {
    setNewTodoPriority(Number(e.target.value)); // 数値型に変換
  };

  const addNewTodo = () => {
    const newTodo: Todo = {
      id: uuid(),
      name: newTodoName,
      isDone: false,
      priority: newTodoPriority, // ◀◀ 追加
      deadline: new Date(2024, 10, 13),
    };
    const updatedTodos = [...todos, newTodo];
    setTodos(updatedTodos);
    setNewTodoName("");
    setNewTodoPriority(3); // ◀◀ 追加
  };

  return (
    <div className="mx-4 mt-10 max-w-2xl md:mx-auto">
      <h1 className="mb-4 text-2xl font-bold">TodoApp</h1>
      <div className="mb-4">
        <WelcomeMessage
          name="寝屋川タヌキ"
          uncompletedCount={uncompletedCount}
        />
      </div>
      <TodoList todos={todos} />

      <div className="mt-5 space-y-2 rounded-md border p-3">
        <h2 className="text-lg font-bold">新しいタスクの追加</h2>
        <div className="flex items-center space-x-2">
          <label className="font-bold" htmlFor="newTodoName">
            名前
          </label>
          <input
            id="newTodoName"
            type="text"
            value={newTodoName}
            onChange={updateNewTodoName}
            className="grow rounded-md border p-2"
            placeholder="2文字以上、32文字以内で入力してください"
          />
        </div>

        {/* ラジオボタンの実装 ここから... */}
        <div className="flex gap-5">
          <div className="font-bold">優先度</div>
          {[1, 2, 3].map((value) => (
            <label key={value} className="flex items-center space-x-1">
              <input
                id={`priority-${value}`}
                name="priorityGroup"
                type="radio"
                value={value}
                checked={newTodoPriority === value}
                onChange={updateNewTodoPriority}
              />
              <span>{value}</span>
            </label>
          ))}
        </div>
        {/* ...ここまで */}

        <button
          type="button"
          onClick={addNewTodo}
          className="rounded-md bg-indigo-500 px-3 py-1 font-bold text-white hover:bg-indigo-600"
        >
          追加
        </button>
      </div>
    </div>
  );
};

export default App;

はじめに 第11行目 で、新規タスクの優先度を「状態」として管理するための newTodoPrioritysetNewTodoPriority を用意していることを確認してください。また、その初期値として 「数値型」の 3 を設定していることを確認してください。

そして、ラジオボタンが操作されたタイミングで 第21行目 で定義した updateNewTodoPriority がコールされて、そのなかで setNewTodoPriority で状態が更新される仕組みになっていることを確認してください。なお、e.target.value で取得される値は <input>タグの種別や属性設定に関係なく常に「文字列型」になること に注意してください。そのため 第22行目 では、Number 関数を使って「文字列型」から「数値型」に変換して setNewTodoPriority にセットしています。

Number関数を使用していないと、次のようにエラーとなります。

img

JSX部分の「ラジオボタンの実装」は、「テキストボックスの実装」と比較するとかなり複雑になっています。次に、以下の点を意識しながら 第69行目第84行 のコードを読解してください。

4.1 補足: onChange属性の設定

第21行目 の関数 updateNewTodoPriority定義せずに、第77行目onChange「アロー関数形式」で「優先度を設定する処理」を直接的に記述すること も可能です。

<input
  id={`priority-${value}`}
  name="priorityGroup"
  type="radio"
  value={value}
  checked={newTodoPriority === value}
  onChange={(e) => setNewTodoPriority(Number(e.target.value))} // ◀◀ 注目
/>

上記のようなテクニックは、Reactでは頻繁に使われるので覚えておいてください。特に処理が短い場合は、こちらの記法が推奨されます。また、ここでは型推論が有効になるので、以下のように長々と「型」を記述しなくても良いことも嬉しいです。

onChange={(e:React.ChangeEvent<HTMLInputElement>) => setNewTodoPriority(Number(e.target.value))}

4.2 補足: mapを使用せずにラジオボタンを構成する例

map を使用せずに 第67行目第82行目 に相当するJSXを記述すると (ラジオボタンを実装すると) 次のようになります。

<div className="flex gap-5">
  <div className="font-bold">優先度</div>
  <label className="flex items-center space-x-1">
    <input
      id="priority-1"
      name="priorityGroup"
      type="radio"
      value="1"
      checked={newTodoPriority === 1}
      onChange={updateNewTodoPriority}
    />
    <span>1</span>
  </label>
  <label className="flex items-center space-x-1">
    <input
      id="priority-2"
      name="priorityGroup"
      type="radio"
      value="2"
      checked={newTodoPriority === 2}
      onChange={updateNewTodoPriority}
    />
    <span>2</span>
  </label>
  <label className="flex items-center space-x-1">
    <input
      id="priority-3"
      name="priorityGroup"
      type="radio"
      value="3"
      checked={newTodoPriority === 3}
      onChange={updateNewTodoPriority}
    />
    <span>3</span>
  </label>
</div>

重複する部分が多いので map を使ってJSXを構成するほうがスマートであることが分かると思います。

4.3 ラジオボタンのラベルに任意の値を使用する方法

ラジオボタンの「ラベル」を次のように任意の値 (例えば 高・中・低 など) を設定したいときの実装例を示します。

img

このようにするためには、JSXを次のように変更します。[1,2,3]["高", "中", "低"] に変えて、その他、いくつかの属性を変えています。

<div className="flex gap-5">
  <div className="font-bold">優先度</div>
  {["高", "中", "低"].map((value, index) => ( // ◀◀ 注目 index を追加
    <label key={value} className="flex items-center space-x-1">
      <input
        id={`priority-${value}`}
        name="priorityGroup"
        type="radio"
        value={value}
        checked={newTodoPriority === index + 1} // ◀◀ 注目
        onChange={updateNewTodoPriority}
      />
      <span>{value}</span>
    </label>
  ))}
</div>

さらに、上記の変更にあわせて関数 updateNewTodoPriority を次のようにします。上記の変更によって e.target.value の値が のいずれかになるので、それを switch で振り分けています。

const updateNewTodoPriority = (e: React.ChangeEvent<HTMLInputElement>) => {
  switch (e.target.value) {
    case "高":
      setNewTodoPriority(1);
      break;
    case "中":
      setNewTodoPriority(2);
      break;
    case "低":
      setNewTodoPriority(3);
      break;
  }
};

なお、上記の以外にも value={value}value={index+1} のようにしてしまうスマートな方法もあります (この方法では、そもそも updateNewTodoPriority を変更する必要がありません)。

5 期限を設定する日時設定UIの追加

新規タスクの期限 (deadline) を設定するための「日時設定UI」を実装する例を以下に示します。

img

日時設定UIには、属性を type="datetime-local" に設定した <input> タグを使用します。なお、Chrome において「2024/10/31 () 17:20」のように括弧の内部が表示されないのは仕様 (バグ?) です。

5.1 日時情報の更新 (UI→コード)

前回講義で仕様を決めたように「特に期限を設定しないTodo」については deadlinenull として扱うものとします。

export type Todo = {
  id: string;
  name: string;
  isDone: boolean;
  priority: number;
  deadline: Date | null; // ◀◀ 注目
};

そのため、第13行目 (全体コード参照) のように、期限を扱う「状態」については useState<Date | null> のように 型を明示して初期化 する必要があります。このように、値が「Date型」または「null」のどちらかになり得るような型Union型 (ユニオン型) とよびます。Union型では 初期値から型推論が不可能 であるため明示的に型を示す必要があります。

const [newTodoDeadline, setNewTodoDeadline] = useState<Date | null>(null);

もし、ここで次のように 型を指定せずに初期化 してしまうと、newTodoDeadlinenull型の変数 (=値として null だけを持つことができる変数) になってしまうので注意してください。

const [newTodoDeadline, setNewTodoDeadline] = useState(null);

つづいて 第26行目 からは、以下のように 日時設定UIでユーザにより値が変更されたとき にコールされる「updateDeadline関数」を定義しています。

const updateDeadline = (e: React.ChangeEvent<HTMLInputElement>) => {
  const dt = e.target.value; // UIで日時が未設定のときは空文字列 "" が dt に格納される
  console.log(`UI操作で日時が "${dt}" (${typeof dt}型) に変更されました。`);
  setNewTodoDeadline(dt === "" ? null : new Date(dt));
};

日時設定UIを操作して日時を選択すると e.target.value からは 「2024-11-30T15:30」のようなISO8601形式の文字列 が得られます。実際に 第28行目 のコンソール出力の内容を確認してください。また、以下の図のように日時設定UIで「削除」を選択したときは e.target.value からは 空文字 ("") が得られます。

img

このような「2024-11-30T15:30形式の文字列」もしくは「空文字」を、Date | null 型の deadline にセットするために、第29行目 では dt === "" ? null : new Date(dt) のように 条件演算子 を使った変換をしています。条件演算子 (三項演算子) については第02回講義で学習済みです。

これにより、dt === "" が「真」となるとき、newTodoDeadline には null がセットされます。また、そうでないときは new Date("2024-11-30T15:30") で生成される「Data型オブジェクト」newTodoDeadline にセットされます。Dateクラス"2024-11-30T15:30" 形式の文字列を使った初期化にも対応しています (ただし、ブラウザ互換性などを考慮して、より安全に使用するためには new Date(Date.parse(dt)) とすることが推奨されます)。

なお、空文字 "" は、単体で false として評価されるため (空文字は「falsyな値」であるため)、第29行目は、次のように記述することもできます。

setNewTodoDeadline(dt ? new Date(Date.parse(dt)) : null);

5.2 日時情報の更新 (コード→UI)

上記とは逆に、Date | null 型の deadline から、日時設定UI (日時設定フォーム) に値を反映させるために、 第99行目 で以下のように工夫を行なって value属性 を設定しています。

<input
  type="datetime-local"
  id="deadline"
  value={
    newTodoDeadline
      ? dayjs(newTodoDeadline).format("YYYY-MM-DDTHH:mm:ss")
      : ""
  }
  onChange={updateDeadline}
  className="rounded-md border border-gray-400 px-2 py-0.5"
/>

null は単体で false の扱いになるため (null は「falsyな値」であるため)、newTodoDeadlinenull のときは、value属性に「空文字 "" 」がセットされます。

一方で newTodoDeadlinenull 以外のときは、dayjs を利用してvalue属性に「ISO8601形式 "YYYY-MM-DDTHH:mm:ss" に変換された文字列」がセットされるようにしています (注意: ここでは「秒」まで指定しないと Safari では意図した動作をしない可能性があります)。

このように「日時」に関しては、コードとUIの間で双方向の型変換 が必要となり、ブラウザによる挙動の違い (対応している日時フォーマットの違い) やタイムゾーン、使用するライブラリを十分に考慮しながら慎重に実装する必要があります。

5.3 「truthyな値」と「falsyな値」

JavaScript / TypeScript において、条件式 (if文の条件部分や条件演算子の条件部分など) で 論理値として評価されるとき に、true と解釈される値を「truthyな値」、false と解釈される値を「falsyな値」といいます。

falsyな値」は false""(空文字)、nullundefined0-00nNaNで、それ以外は 「truthyな値」となります。

特に注意すべきは、[] (空配列) や、{} (空オブジェクト)、"0" (文字列の0) で、これらは truthyな値 となります。

5.3.1 定着確認

定数 hoge に代入される値を答えよ。

確認にはpaiza.ioなどを利用してください。

6 バリデーション

現状では、新規タスクの「名前」のテキストボックスが空欄であっても、「追加」のボタンを押下すればタスクが追加できてしまいます。ここでは「2文字以上、32文字以内でテキストボックスに名前が入力されていないとき」はタスクが追加できないように設定していきます。

単純には「addNewTodo関数」の先頭に、次のような処理 (第02行目~第04行目) を追加すれば、この処理が可能になります。実際に試してみてください。

const addNewTodo = () => {
  if (newTodoName.length < 2 || newTodoName.length > 32) {
    return;
  }
  const newTodo: Todo = {
    id: uuid(),
    name: newTodoName,
    isDone: false,
    priority: newTodoPriority,
    deadline: newTodoDeadline,
  };
  const updatedTodos = [...todos, newTodo];
  setTodos(updatedTodos);
  setNewTodoName("");
  setNewTodoPriority(3);
  setNewTodoDeadline(null);
};

上記の処理によって、「追加」のボタンを押下しても newTodoName が条件を満たしていなければタスクが追加できないようになりました。しかし、このままでは UI/UX 的には大きな課題が残ります (ユーザが「なぜタスクが追加できないのか」に気づきづらい不親切な UI/UX になっている)。

そこで、条件を満たしていないときは、以下のように画面上に注意喚起のメッセージを表示するようにアプリを改良していきます。このために newTodoNameError という 文字列型の変数 (状態) を導入します。そして、テキストボックスに入力されている値が適切であれば、この newTodoNameError には空文字 "" が格納し、そうでないときは 不備を指摘する文字列 を格納するようにします。また、newTodoNameError を参照して、不備があるときは 「追加」ボタンが押下できない ようにします。

img

特に 第79行目 から 第107行目 にかけて大幅に書き換えをしています (その他の部分についても編集や追記があるので注意してください)。

{/* 編集: ここから... */}
<div>
  <div className="flex items-center space-x-2">
    <label className="font-bold" htmlFor="newTodoName">
      名前
    </label>
    <input
      id="newTodoName"
      type="text"
      value={newTodoName}
      onChange={updateNewTodoName}
      className={twMerge(
        "grow rounded-md border p-2",
        newTodoNameError && "border-red-500 outline-red-500"
      )}
      placeholder="2文字以上、32文字以内で入力してください"
    />
  </div>
  {newTodoNameError && (
    <div className="ml-10 flex items-center space-x-1 text-sm font-bold text-red-500">
      <FontAwesomeIcon
        icon={faTriangleExclamation}
        className="mr-0.5"
      />
      <div>{newTodoNameError}</div>
    </div>
  )}
</div>
{/* ...ここまで */}

このコードのなかで特に注意してほしいのは 第97行目 の論理演算子 && を使った処理になります。この処理は、React では頻繁に使用されるテクニックで 条件付きレンダリング と呼ばれます。

React のJSX構文のなかで、例えば { hoge && (<div>こんにちは</div>)} というコードを書くと、変数 hogefalsenullundefined のいずれかのときは、そこに何もレンダリングされません (つまり {...} の部分が存在していないのと同等の扱いとなります)。

一方で、hogetruthyな値 のときは <div>こんにちは</div> がレンダリングされます。ただし、falseな値 でも 0"" は例外的に、以下のようになるので注意してください。

同様に、第92行目 でもこのテクニックを使用しています。twMerge前回講義でインストールした tailwind-merge ライブラリ から、以下のようにインポートしている関数です。この twMergeTailwind CSS のクラスを衝突を回避しながら結合する機能を持った関数 となります。

import { twMerge } from "tailwind-merge"; // ◀◀ 追加

第92行目twMerge("grow rounded-md border p-2", newTodoNameError && "border-red-500 outline-red-500")} は、newTodoNameError が「空文字 "" のとき」は twMerge("grow rounded-md border p-2") と等価になります。

そうでないときは twMerge("grow rounded-md border p-2", "border-red-500 outline-red-500")} と等価になります。つまり、新規タスクの名前に不備があるとき (文字数が 2文字以上32文字以内になっていないとき) テキストボックスの枠を赤線にするクラスを追加する仕組み をつくっています。

twMerge の必要性については Tailwind CSS初心者が絶対ハマる落とし穴@ ムーザルちゃんねる (動画) を参考にしてください。

6.1 補足: React Hook Form と Zod

Reactにおいて、テキストボックスやラジオボタンなどを用いた フォーム処理 にはReactHookFormというライブラリが活用できます。このTodoアプリのように入力項目が少ない場合は、現状のように useState を使ったシンプルな実装で十分ですが、フォームの規模が大きくなるほど ReactHookFormの導入効果 が高まります。

また、フォームのバリデーションにはzodというライブラリが活用できます。これは、ReactHookFormと組み合わせて使用可能であり、以下ような型安全なバリデーションルールを宣言的に記述できます。TypeScriptとの相性も良く、入力値の型推論も自動的に行われるため、開発効率の向上が期待できます。

フロントエンド開発を目指す人は、これらのライブラリを積極的に利用して課題1に取り組んでください。

7 既存Todoの 完了/未完了 の切り替え

既存の Todo について isDonetrue/false を切り替える機能を実装します。ここでは、次のように チェックボックス と紐づけて isDone の値 (つまり完了/未完了) を切り替えるようにしていきます。

img

ここからは意図的に App.tsx TodoList.tsx の全体コードを提供しません。ここまでの内容を十分に理解し、以降で提供されるコードの断片を「自分のコードのどこに配置すればよいか?」を考えながら作業を進めてください。

まずは、src/App.tsx のなかに、次のような updateIsDone 関数を追加します。

const updateIsDone = (id: string, value: boolean) => {...}

この関数は、引数で受け取った id と一致する Todo について、その isDone プロパティの値を引数 value の値で上書きするものです。例えば updateIsDone("a001",false) のように関数をコールしたら、ida001 のTodoの isDonefalse に更新する機能を持たせます。特に、todos が変更されたことを React が確実に検知できるように map」と「スプレッド構文」を使ってイミュータブルな操作 で処理するように実装する必要があります。

次の updateIsDonesrc/App.tsx のなかの 適切な位置 に追記してください。

const updateIsDone = (id: string, value: boolean) => {
  const updatedTodos = todos.map((todo) => {
    if (todo.id === id) {
      return { ...todo, isDone: value }; // スプレッド構文
    } else {
      return todo;
    }
  });
  setTodos(updatedTodos);
};

次に src/TodoList.tsx において、各Todoの情報を表示している UI に チェックボックス を追加し、そこに checked={todo.isDone} の属性を設定して「完了/未完了の状態 (isDoneプロパティ)」と「チェックボックスのオンオフ」を連動させます。

以下のコードを参考に、src/TodoList.tsx のなかでTodoの情報を表示している UI に「チェックボックス」を追加してください。

<div>
  {todos.map((todo) => (
    {/* 略 */}
    <input
      type="checkbox"
      checked={todo.isDone} // ◀◀ 注目
      className="mr-1.5 cursor-pointer"
    />
    {/* 略 */}
  )}
</div>

これを実装すると、次のように Todo の UI に チェックボックス がついて、isDone の値 (初期値) を反映した オン/オフ がつきます。ただし、現状では、まだ チェックボックスをクリックしてもオンオフが変化 しません。

デベロッパーツール (F12で起動) を確認すると、コンソール出力には「You provided a ‘checked’ prop to a form field without an ‘onChange’ handler. This will render a read-only field. If the field should be mutable use ‘defaultChecked’. Otherwise, set either ‘onChange’ or ‘readOnly’.」 という警告が表示されています。

img

チェックボックスのクリックによって、当該Todoの isDone プロパティを変更して、UI表示も更新するためには、先に定義した「updateIsDone関数」をコールする 必要があります。

ただし、updateIsDone 関数は Appコンポーネント (src/App.tsx) の内部に定義しているため、src/TodoList.tsx からは直接的にコールすることはできません。このようなとき、React では Props を使って親コンポーネントから子コンポーネントに「関数」を渡す ということで解決します。

まずは、src/TodoList.tsx において、Props 経由で updateIsDone を受け取れるように Propsの型定義 を次のように変更します。これにより 文字列型と真偽値型の引数を持ち、戻り値がない関数を updateIsDone という名前 で受け取れるようになります。

type Props = {
  todos: Todo[];
  updateIsDone: (id: string, value: boolean) => void; // ◀◀ 追加
};

また、ユーザによってチェックボックスが操作されたタイミングで、Props 経由で受け取った updateIsDone をコールするように、チェックボックスの「onChange属性」を次のように設定します。ここでは、先に紹介したようにonChangeにアロー関数形式で直接的に処理を記述しています。

<input
  type="checkbox"
  checked={todo.isDone}
  onChange={(e) => props.updateIsDone(todo.id, e.target.checked)}
  className="mr-1.5 cursor-pointer"
/>

最後に src/App.tsx のなかで、TodoListコンポーネントに対して「updateIsDone関数」を渡すように変更します。

<TodoList todos={todos}/>
<TodoList todos={todos} updateIsDone={updateIsDone} />

以上で、チェックボックスを操作して「既存のTodoの完了/未完了を切り替えること」が可能になりました。内部的にも todos のなかの当該Todoの isDone プロパティが変更されています。

7.1 NG実装:ミュータブルな操作でオブジェクト配列の要素を変更

上記の解説では updateIsDone を次のようにスプレッド構文を使用して イミュータブルな操作 で処理するように実装しました。

const updateIsDone = (id: string, value: boolean) => {
  const updatedTodos = todos.map((todo) => {
    if (todo.id === id) {
      return { ...todo, isDone: value }; // スプレッド構文
    } else {
      return todo;
    }
  });
  setTodos(updatedTodos);
};

これを次のようにミュータブルな操作で処理してしまうと意図するように機能しなくなります。実際に書き換えて確かめてください。

const updateIsDone = (id: string, value: boolean) => {
  for (const todo of todos) {
    if (todo.id === id) {
      todo.isDone = value; // オブジェクトを直接変更
    }
  }
  setTodos(todos);
};

これは繰返し説明しているように、Reactでは画面の再レンダリングが「参照の変更を検知すること」で実行されるためです。上記のような処理では setTodos(todos) をコールしても、引数の todos 参照が同じ配列を指したままとなるため、Reactは変更を検知せず再レンダリングは発生しません。

7.2 完了済みのタスクを削除するボタンの追加

完了済みのタスクを一括削除する機能を実装します。第03回講義で学んだ「filter」を利用しています。

src/App.tsx に次の removeCompletedTodos 関数を追加してください。

const removeCompletedTodos = () => {
  const updatedTodos = todos.filter((todo) => !todo.isDone);
  setTodos(updatedTodos);
};

つづいて、removeCompletedTodos 関数を呼び出すためのボタンを追加してください。

<button
  type="button"
  onClick={removeCompletedTodos}
  className={
    "mt-5 rounded-md bg-red-500 px-3 py-1 font-bold text-white hover:bg-red-600"
  }
>
  完了済みのタスクを削除
</button>

「完了済みのタスクを削除」のボタンを押下することで、実際に完了済みタスクの一括削除ができることを確認してください。

img

8 タスクの個別削除

次のように完了/未完了に関係なく タスクを個別に削除する機能 を実装します。

img

まずは src/App.tsx のなかに、次の remove 関数を追加してください。これは引数で受け取った id に該当する Todo を削除する関数になります。

const remove = (id: string) => {
  const updatedTodos = todos.filter((todo) => todo.id !== id);
  setTodos(updatedTodos);
};

そして、この remove 関数を Props として「TodoListコンポーネント」に渡すようにします。

<TodoList todos={todos} updateIsDone={updateIsDone} remove={remove} />

次に、src/TodoList.tsx で、Propsの型定義 を次のように変更します。

type Props = {
  todos: Todo[];
  updateIsDone: (id: string, value: boolean) => void;
  remove: (id: string) => void; // ◀◀ 追加
};

さらに、個々のTodoを表示するUIに「削除」のボタンを追加し、クリックされたときに、id を引数にして remove 関数をコールするようにします。

<button
  onClick={() => props.remove(todo.id)}
  className="rounded-md bg-slate-200 px-2 py-1 text-sm font-bold text-white hover:bg-red-500"
>
  削除
</button>

9 リファクタリング: コンポーネントに分割

TodoListコンポーネントが肥大化してきたので、個々のTodoを「TodoItemコンポーネント (src/TodoItem.tsx)」に切り分けます。Reactでは、コンポーネントに分割可能なものは、できるだけコンポーネントに分割するようにしてください。

実装例を以下に示します。

まずは、個々のTodo情報を表示するための src/TodoItem.tsx を新規作成します。ほとんどの部分は、既存の src/TodoList.tsx から移植して作成できると思います。

(以下のコードは、必要最低限の情報とUIだけを記述したものです)

import React from "react";
import type { Todo } from "./types";

type Props = {
  todo: Todo;
  updateIsDone: (id: string, value: boolean) => void;
  remove: (id: string) => void;
};

const TodoItem = (props: Props) => {
  const todo = props.todo;
  return (
    <div className="flex items-center justify-between">
      <div className="flex items-center">
        <input
          type="checkbox"
          checked={todo.isDone}
          onChange={(e) => props.updateIsDone(todo.id, e.target.checked)}
          className="mr-1.5 cursor-pointer"
        />
        {todo.name}
      </div>
      <div>
        <button
          onClick={() => props.remove(todo.id)}
          className="rounded-md bg-slate-200 px-2 py-1 text-sm font-bold text-white hover:bg-red-500"
        >
          削除
        </button>
      </div>
    </div>
  );
};

export default TodoItem;

上記で新規作成した「TodoItemコンポーネント」を、既存の「TodoListコンポーネント」から呼び出すようにします。特に 第03行目 と、第24行目 から 第31行目 に注目して読解してください。

import React from "react";
import type { Todo } from "./types";
import TodoItem from "./TodoItem"; // ◀◀ 追加

type Props = {
  todos: Todo[];
  updateIsDone: (id: string, value: boolean) => void;
  remove: (id: string) => void;
};

const TodoList = (props: Props) => {
  const todos = props.todos;

  if (todos.length === 0) {
    return (
      <div className="text-red-500">
        現在、登録されているタスクはありません。
      </div>
    );
  }

  return (
    <div className="space-y-2">
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          remove={props.remove}
          updateIsDone={props.updateIsDone}
        />
      ))}
    </div>
  );
};

export default TodoList;

10 LocalStorageを利用したデータの永続化

現状のTodoアプリは、Todoの追加や削除をしてもブラウザをリロード (再読み込み) すると初期状態に戻ってしまいます。このままでは、Todoアプリとして実用的に使えないため データの永続化 (Data Persistence) の機能が必要となります。「データの永続化」とは、簡単に言えば ブラウザをリロードしたとき次回アクセスしたとき にも継続的にアプリを利用できるように データや状態を不揮発性の領域に保存すること です。

ウェブアプリにおいて「データの永続化」は、通常、バックエンド側 (サーバ側) でデータベース (RDBやNoSQLなど) を利用して実装されます。バックエンドでの実装することで、ユーザは異なるデバイスからアクセスしても (ユーザ認証を経たうえで) 継続的にアプリの利用ができます。

しかし、まだ現時点ではバックエンド開発は学んでいないので、ここではブラウザの「LocalStorage」という機能を使って、フロントエンドでのデータの永続化 を実装します。LocalStorage とは、ウェブブラウザに組み込まれている キーバリュー型のストレージ機能 (Pythonの dict のようなシンプルな 「キー(key)」と「値(Value)」のペアでデータを保存する機能) で、ブラウザを終了してもデータが残ることが特徴です。

10.1 LocalStorage とは

LocalStorageは、JavaScriptの Window.localStorage API を通じて利用可能で、保存可能なデータは 文字列 (string型) のみ ですが、約5MB までのデータが保存可能となっています。

LocalStorageは ブラウザごとに独立した保存領域 を持ちます。例えば https://hoge777.github.io/react-todo-app/ にホストされた TodoApp に2人のユーザ「寝屋川タヌキ」と「萱島ウサギ」がアクセスした場合、各データは 各ユーザーが使用するデバイスのブラウザに独立して保存 されます。

そのため、例えば寝屋川タヌキが「高専に侵入」というTodoを追加しても、萱島ウサギは影響を受けません。

LocalStorage の容量制限

LocalStorage は、1オリジンあたり約5MB が一般的な容量制限になっています。ここでの「オリジン」とは「プロトコル(http/https)」「ドメイン」「ポート」の組み合わせを指します。

例えば、Todoアプリを GitHub Pagaes にデプロイするとすれば、次のようになります。

また、ローカル環境の開発サーバでホストするとすれば、次のようになります。

10.1.1 定着確認

10.2 LocalStorage を利用した保存機能の実装

現在のTodoアプリに LocalStorage を組み込んでデータを永続化するには src/App.tsx に次のように実装します (useEffect という Reactの Hook の利用が実装のポイントとなります)。

まずは、実際に動かしてみてください (リロードしてもTodoリストが初期状態に戻ってしまわないこと確認してください)。

import { useState, useEffect } from "react"; // ◀◀ 追加
// ~ 省略 ~

const App = () => {
  const [todos, setTodos] = useState<Todo[]>([]); // ◀◀ 編集
  const [newTodoName, setNewTodoName] = useState("");
  const [newTodoPriority, setNewTodoPriority] = useState(3);
  const [newTodoDeadline, setNewTodoDeadline] = useState<Date | null>(null);
  const [newTodoNameError, setNewTodoNameError] = useState("");

  const [initialized, setInitialized] = useState(false); // ◀◀ 追加
  const localStorageKey = "TodoApp"; // ◀◀ 追加

  // App コンポーネントの初回実行時のみLocalStorageからTodoデータを復元
  useEffect(() => {
    const todoJsonStr = localStorage.getItem(localStorageKey);
    if (todoJsonStr && todoJsonStr !== "[]") {
      const storedTodos: Todo[] = JSON.parse(todoJsonStr);
      const convertedTodos = storedTodos.map((todo) => ({
        ...todo,
        deadline: todo.deadline ? new Date(todo.deadline) : null,
      }));
      setTodos(convertedTodos);
    } else {
      // LocalStorage にデータがない場合は initTodos をセットする
      setTodos(initTodos);
    }
    setInitialized(true);
  }, []);

  // 状態 todos または initialized に変更があったときTodoデータを保存
  useEffect(() => {
    if (initialized) {
      localStorage.setItem(localStorageKey, JSON.stringify(todos));
    }
  }, [todos, initialized]);

  const uncompletedCount = todos.filter(
    (todo: Todo) => !todo.isDone
  ).length;

  // ~ 以下、省略 ~

ここでは useEffect が重要な役割を果たします。

まず、第15行目第29行目useEffect(() => {...}, []) によって Appコンポーネントの 初回の実行時のみ (ブラウザでリロードされたときは、そのリロードの実行時のみ) LocalStorage から Todoデータ をロードして todos にセットする仕組みをつくっています。

また、第32行目第36行目useEffect(() => {...}, [todos, initialized]) によって todos (または initialized) に更新があるときに、LocalStorage に todos をセーブする仕組みをつくっています。

じつは、ここまでの解説では、あえて触れていはいなかったのですが、「Reactによる画面更新のための再レンダリング」=第04回行目からの App関数 を再実行 という内部処理になっています。つまり、新規タスクの名前設定用の テキストボックスに1文字入力するたびに Reactにより画面の再レンダリングが実行されますが、その裏側では「App関数の再実行」が行なわれています (このあたりの仕組みは後述の動画を参照してください)。そのため、useEffect を利用しないと テキストボックスに1文字打ち込むたびにTodoデータのロードやセーブが実行 されてしまいます。ここでは、LocalStorage を使っているため頻繁にロードとセーブをしても大きな問題はありません。しかし、一般的なウェブアプリでは ウェブAPIを使ってデータの永続化 を行なうため、テキストボックスに1文字打ち込むたびにAPIを叩くような実装にすると、短期間でAPIの利用上限に到達したり、膨大な課金が発生してしまいます。注意してください。

useEffect の理解については 初心者向け!ReactのuseEffectが何してるのか20分で説明@ Code Tips (動画) やウェブ検索、生成AIを活用してください。

この他、永続化の処理では「LocalStorageには文字列しか保存できない」という制約に対応するために JSON.parse()JSON.stringify() を利用しています。特に、Date型の値は JSON.stringify() によって文字型に変換・保存された後、JSON.stringify() にはDate型の値に復元されない点に注意してください (文字列型として復元されます)。そのため 第21行目 のような工夫がされています。

以上のように、この永続化の処理は様々な工夫が詰められているので、色々と実験したり読み解いたりして理解を深めてください。

10.3 ローカルストレージの管理

ローカルストレージの管理 (確認・編集・削除) は、以下のようにウェブブラウザのデベロッパーツール (F12 で起動) から行なうことができます。

img

上記の❸で、右クリックして「削除」を選択すれば手動で当該のローカルストレージを削除することができます。

11 Reactアプリを GitHub Pages にデプロイする手順

Todoアプリを GitHub Pages にデプロイ するための手順について解説します。

以前に HTML/CSS でポートフォリオサイトを作成したときとは違って、単純に GitHub Pages の機能を有効化するだけでは「Reactアプリは機能しない」ため注意 してください。

11.1 準備

VSCodeのターミナルからgh-pagesというパッケージのインストールしてください。開発だけに使用するので -D オプションを付けてください。

npm i -D gh-pages

また、同様に @types/node という型定義ファイルも -D オプションを付けてインストールしてください。

npm i -D @types/node

プロジェクトフォルダを GitHub の パブリックリポジトリ として発行してください。既にリポジトリを発行済みの場合は「コミット」と「同期 (Push)」を実行しておいてください。

img
img

11.2 初回設定

これは「初回のみ必要な処理」となります。

注意: 必ず main ブランチの コミットとプッシュ (同期) を完了させた状態 で、以下の処理を実行してください。main ブランチのなかに「未保存のファイル」や「コミットしていないファイルが存在する状態」で以下の処理を実行すると予期せぬ問題が発生します。

VSCodeのターミナルから順番に実行してください (1行ずつ応答を確認しながら実行してください)。

git checkout --orphan gh-pages
git rm -rf .
git commit --allow-empty -m "Initial gh-pages commit"
git push origin gh-pages
git checkout main

以上は、GitHub Pages でウェブサイトをホストするための準備作業 として一般的に使用される一連のコマンドです。詳細について知りたいときは、生成AIを利用してください。

なお、git rm -rf . によって一時的に全ファイルが消去されますがgit checkout main で mainブランチに戻ると復活するので安心してください。

11.3 GitHub Pages の設定

ウェブブラウザで「Todoアプリ」のリポジトリに移動して、以下のように設定されていることを確認してください。もし、自動的に設定されていない場合は、図のように設定してください。

img

なお、この時点では、https://xxxx.github.io/react-todo-app/ にアクセスしても 「404」 となります。実際に確認してください。

11.4 index.html の編集と 404.html の追加

プロジェクトフォルダの直下にある index.html<head> タグの子要素として、次のような <script> タグを追加してください。index.html の全体コードはこちらのようになります。

<script type="text/javascript">
  (function (l) {
    if (l.search[1] === "/") {
      var decoded = l.search
        .slice(1)
        .split("&")
        .map(function (s) {
          return s.replace(/~and~/g, "&");
        })
        .join("?");
      window.history.replaceState(
        null,
        null,
        l.pathname.slice(0, -1) + decoded + l.hash
      );
    }
  })(window.location);
</script>

また、同様に プロジェクトフォルダの直下404.html というファイルを新規作成してこちらのコードを貼付けて保存してください。

img

11.5 各種設定ファイルの変更

package.json のなかに "homepage" を追加し、さらに "scripts" のなかに "predeploy""deploy" を追加してください。

"homepage": "https://xxxx.github.io/react-todo-app",
"scripts": {
  "dev": "vite",
  "build": "tsc -b && vite build",
  "lint": "eslint .",
  "preview": "vite preview",
  "predeploy": "npm run build",
  "deploy": "gh-pages -d dist"
},

設定項目を追加する際は、カンマ , 等の付け忘れに注意してください。また、JSONファイルに「コメント」を追加するとビルドエラーになる可能性があるので注意してください。

img

vite.config.ts を以下のように編集してください。

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import path from "path"; // ◀◀ 追加

const repositoryName = "react-todo-app"; // ◀◀ 追加

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
  // ▼▼ 追加 ここから ▼▼
  base: process.env.NODE_ENV === "production" ? `/${repositoryName}/` : "/",
  build: {
    rollupOptions: {
      input: {
        main: path.resolve(__dirname, "index.html"),
        404: path.resolve(__dirname, "404.html"),
      },
    },
  },
  // ▲▲ 追加 ここまで ▲▲
  server: {
    port: 3000,
    strictPort: false,
    open: true,
  },
});

一旦、ここまでの内容で「コミット」と「同期 (Push)」をしておいてください。以上で設定は終わりです。

11.6 発行 Publish

ここからは、Todoアプリを書き換えて、その内容を GitHub Pages に反映したいときには、その都度、実行する必要がある処理 になります。

VSCodeのターミナルから以下のコマンドを実行してください。

npm run predeploy

このコマンドは実質的に npm run build と同じです。エラーがあれば修正してください。

特にエラーがなければ、以下のコマンドを実行してください。

npm run deploy

以下のように「Published」と出力されたらデプロイが 成功🎉 (完了) です。以降、ソースファイルのコミットとプッシュにあわせて、適宜、上記の npm run deploy でデプロイを実行してください。 デプロイ操作をしない限り、ソースコードをコミット&プッシュしても、GitHub Pages にホストしているTodoアプリは更新されません

PS C:\Users\xxxx\Documents\react-todo-app> npm run deploy

> react-todo-app@0.0.0 predeploy
> npm run build


> react-todo-app@0.0.0 build
> tsc -b && vite build

vite v5.4.9 building for production...
✓ 69 modules transformed.
dist/index.html                  1.58 kB │ gzip:  0.77 kB
dist/404.html                    1.92 kB │ gzip:  0.82 kB
dist/assets/main-wUGgSDZ4.css    8.60 kB │ gzip:  2.30 kB
dist/assets/main-DWqE2Lov.js   232.21 kB │ gzip: 76.33 kB
✓ built in 1.64s

> react-todo-app@0.0.0 deploy
> gh-pages -d dist

Published

ウェブブラウザから https://xxxx.github.io/react-todo-app/ にアクセスしてTodoアプリが実際に利用できることを確認してください。

img

なお、react-router-domによる ルーティング を使ったReactアプリを GitHub Pages でホストする場合は、さらにひと工夫が求められます。これについては個別に質問してくれれば個別にレクチャします (main.tsx,Router.tsx)。

12 課題1

以上で、(フロントエンドだけで処理が完結するような) React を使った Todoアプリ の「チュートリアル」は完了です。

このチュートリアルで学んだことをベースとして オリジナルのTodoアプリ を開発して、GitHub Pages にデプロイし、その URL を提出してください。ゼロから構築しても、チュートリアルのなかで開発したものを改良する形でも OK です。


提出された課題 (URL) は、知能情報コースの学生 (知能情報コースを志望する1年生を含む)、本校教員に共有します。また、この課題は、インターンシップや就職の選考にポートフォリオとして使えるもの (=自分のスキルをや経験を証明するもの) になるので、力を入れて取り組んで欲しいと考えています。

(プロンプト例)

IT系の就活を意識したポートフォリオでは、「汎用性を追求したアプリよりも、自身の体験や環境に基づくニッチなニーズを満たすアプリの方が圧倒的に受けが良い」と言われました。どういうことですか。