4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React入門】無限にタスクを分割できるアプリを作る

4
Last updated at Posted at 2025-12-11

ジョブカン事業部のアドベントカレンダー12日目です!

この記事では
・Reactを動かせる環境があるぜ
Reactの公式チュートリアルはとりあえず触ったぜ
というレベル感でタスク管理アプリを作りながら復習していきます。

最強のタスク管理アプリ

タスク管理アプリは使っていますか?
私は既存のタスク管理アプリに納得いかない点があります。
それはタスクの分割が有限なことです。

万力の握力を持つ修道士は言いました。「困難は分割せよ」と。
ところが、大抵の困難は一回分割した程度ではまだ困難です。
これならできそうだな、と思えるまで何回でも困難を分割したいですよね。
タスクにラベルをつけて分類する機能はどのアプリにもありますが、本当はもっと細かくタスクを分けたいです。

タスク管理アプリといえばプログラミングの試し切りの定番です。
最近触り始めたReactの勉強のためにも、自作しましょう!

タスクが持っていてほしい情報はタイトル・優先度・締め切りと色々ありますが、簡単のため今回はタイトルだけを扱います。
タスクに対してやりたい操作をリストアップしてみると...
表示・編集・追加・削除・並び替え・分割といった内容になりそうです。
それぞれReactとTypeScriptを使って実装していきます!

React基本の考え

Reactの基本的な考え方は関数でHTMLを作ることです。

const HelloWorld = () => {
  return(
    <h1>Hello, World!!</h1>
  );
};

HTMLというとページ1枚が全部作られているイメージがありますが、Reactではボタンなどを小さなパーツ単位で作り、後で合わせるという方法をとります。
このパーツひとつひとつをコンポーネントといいます。
上のHelloWorldもコンポーネントです
ほかの場所でHelloWorldコンポーネントを使おうとするとこうなります。

<HelloWorld />

これだけで、上記のHelloWorldが返していた<h1>Hello, World!!</h1>を描画できます。

コンポーネントを組み合わせて作ったページは例えばこのようになります。

const Header = ...
const SideBar = ...
const MainContents = ...

const App = () => {
  return (
    <div>
      <Header />
      <div style={{ display: 'flex', minHeight: '300px' }}>
        <Sidebar />
        <MainContent />
      </div>  
    </div>
  );
};

HeaderやSidebarはそれぞれがコンポーネントで、HTMLを返す関数で定義されています。
コンポーネントの中で別のコンポーネントを使って入れ子状態にすることもできます。
この中でいうと、MainContentが入れ子状態になっていそうです。
MainContentコンポーネントの中身をのぞいてみましょう。

MainContent.tsx
const Profile = ...
const Topic = ...

const MainContent = () => {
  const titles = ...
  return (
    <div style={{ display: 'flex' }}>
      <Profile />
      {titles.map((title, index) => (
        <Topic key={index} title={title} />
      ))}
    </div>
  );
};

Appの中に入っているMainContentコンポーネントの中に、さらにProfileコンポーネントやTopicコンポーネントが入っています。
このように小さいパーツを組み合わせてページを作るのがReactの楽しさです。

タスクを表示する

まずはタスクを表示するコンポーネントと、それを表示するアプリを作ります。
今回は簡単のため、タスクにはIDとタイトルだけを持ってもらいます。
するとタスクの型はこのようになりそうです。

TaskCard.tsx
type Task = {
  uuid: string;
  title: string;
};

タスクを表示するコンポーネントに何を返して欲しいかを考えると、uuidをキーに持つdivタグでtitleを表示するHTMLが良さそうです。

イメージ
// タスクのデータ
const task: Task = { uuid: crypto.randomUUID(), title: "テストタスク" };

// 返して欲しい内容
return <div>{task.title}</div>

タスクの表示は全部このコンポーネントを使いまわしたいところですが、タスクの内容をコンポーネントの中に直接書くと、別のタスクを表示するとき使いまわせなくなってしまいます。
そこでタスクの内容は引数で受け取るようにします。
コンポーネントが受け取る引数をpropsといいます。
一般的にHogehoge(コンポーネント名)Propsとして型を定義するみたいです。

TaskCard.tsx
export type Task = {
  uuid: string;
  title: string;
};

type TaskCardProps = {
  task: Task;
};

export const TaskCard = ( {task}: TaskCardProps ) => {
  return (
    <div>{task.title}</div>
  );
};

Task型のtaskという値を受け取って、tasktitleをdivで囲って返すコンポーネント=TaskCardの完成です。
TaskCardとTask型は別のファイルで使いたいので頭にexportをつけます。
これをつけると別のファイルでimportしてコンポーネントとして使えるようになります。

次に、このコンポーネントを表示するアプリ=Appを作ります。
さっそく、TaskCardコンポーネントを使ってみます。

App.tsx
import { TaskCard, type Task } from './TaskCard';

const App = () => {
  const taskList: Task[] = [
    { uuid: crypto.randomUUID(), title: "年末調整" },
    { uuid: crypto.randomUUID(), title: "忘年会の企画" },
    { uuid: crypto.randomUUID(), title: "サンタさんにお手紙" },
  ];

  return (
    <>
      {taskList.map((task: Task) => (
        <TaskCard key={task.uuid} task={task} />
      ))}
    </>
  );
};

TaskCardコンポーネントとTask型をインポートして、Task型の配列であるタスクリストを準備します。
タスクリストをmapで回して、TaskCardコンポーネントに各taskを渡しています。
Reactでは一つのコンポーネントでは一つの要素しか返さないルールになっているので、外側を<></>で囲います。
外側を<></>で囲うと一つの要素という扱いで認識してくれるようになります。
ここで画面を見てみると...
view_taskList.png
タスクの内容が表示されました!

※⚠️mapはちょっと曲者で、書き方を間違えるとエラーも出さずにコンポーネントが消えます。

{hogeItems.map((item) => (
  <HogeComponent item={item}>
))}
{hogeItems.map((item) => {
  return <HogeComponent item={item}>
})}

両方正解なのですが、下の{}を使う方の書き方でreturnを忘れるとコンポーネントが消えます。

これはダメ!.tsx
{hogeItems.map((item) => {
  <HogeComponent item={item}>
})}

「mapはreturnを要求する関数」という定義が先にあって、「()は自動的にreturnを返してくれている」から特別に書かなくてもいいそうです。
ReactやTypeScriptの特徴というよりJavaScriptのルールによるもので、JavaScript歴の短い筆者のような人はハマるかもしれないです。

タスクの編集

タスクの表示ができたところで、次はタスクを書き換えられるようにしていきましょう!
表示部分をinputタグにして、編集できるようにします。

TaskCard.tsx
...
export const TaskCard = ( {task}: TaskCardProps ) => {
  return (
    <div>
-     {task.title}
+     <input value={task.title}/>
    </div>
  );
};

input_taskList.png
これで見た目は入力欄になりましたが...入力しても何も受け付けません😢
Reactの機能の一つuseStateを使って解決していきましょう。

useState

useStateは値の書き換えのために用意された機能です。

const [currentTask, setCurrentTask] = useState(task);

一見「なんだこれ」という見た目をしているuseStateですが、簡略化するとこうです。

const currentTask = task;

constで宣言しているので、普通ならこのcurrentTaskは変更できません。
ところが、useStateからもらったもう一つのアイテムsetCurrentTask、これは実は関数で、引数に新しいcurrentTaskを与えて実行するとcurrentTaskの内容を上書きすることができます。
useStateを使って値を書き換える処理の一例を見ていきましょう。

const initialTask = { 
  uuid: "a73a9385-9699-4ae2-a7dd-3dd769c0632e",
  title: "今日宿題やる",
};
const [task, setTask] = useState(initialTask);

const newTitle = "明日宿題やる";
setTask({ ...task, title: newTitle });

console.log(taskList);
=> { 
  uuid: "a73a9385-9699-4ae2-a7dd-3dd769c0632e",
  title: "明日宿題やる",
}

途中で奇妙な書き方が登場します。
{ ...task, title: newTitle }とはなんでしょう?
これは「taskのコピーを作って、titleだけnewTitleに置き換えてね、ほかはtaskそのままでいいからね」という意味です。
今はtaskの要素が他にuuidしかないので省略しなくてもそこまで大変じゃないのですが、taskの抱える要素が増えていくごとにこの書き方は便利さを増していきます。

このuseStateを使ってtaskの値を変える処理をTaskCardコンポーネントに仕込みます。

TaskCard.tsx
+ import { useState } from 'react';

export const TaskCard = ( task: Task ) => {
+ const [currentTask, setCurrentTask] = useState(task);

  return (
    <div>
-      <input value={task.title} />
+      <input
+        value={currentTask.title}
+        onChange={(e) => {
+          setCurrentTask({
+            ...currentTask,
+            title: e.currentTarget.value,
+          })
+        }}
+      />
    </div>
  );
};

onChangeはinputタグに何かが書き込まれたときに呼ばれるinputタグ備え付けの関数で、ここでは中のsetCurrentTitlee.currentTarget.value(この要素の現在の値)を渡します。
edir_taskList_without_save.png
これで編集できるようになりましたが...なんということでしょう!リロードするとせっかく編集した内容が元に戻ってしまうではありませんか!
これは画面上で書き換えていても、リロードした時には再度初期値が渡されるために起こる現象です。
解決するためには、何らかの方法でtaskListの状態を保存しておく必要があります。

localStorage

データの保存は一般にDBを使いますが、ここでは簡単にデータを保存できるlocalStorageという仕組みを代わりに使って実装します。
localStorageはブラウザにJSON形式データを保存しておける仕組みです。

localStorageの例

const taskList: Task[] = [
  { uuid: "574fd43d-9367-412f-b2e8-d6938c35c5a9", title: "年末調整" },
  { uuid: "251f7f95-9f69-43fc-8752-1f9ae599fbc8", title: "忘年会の企画" },
  { uuid: "7549c8f8-3af5-4b70-b334-3d4c81480821", title: "サンタさんにお手紙" },
];

//データの書き込み
localStorage.setItem("taskData", JSON.stringify(taskList));

//保存されたデータの内容
{
  "taskData":"[
    { uuid: \"574fd43d-9367-412f-b2e8-d6938c35c5a9\", title: \"年末調整\" },
    { uuid: \"251f7f95-9f69-43fc-8752-1f9ae599fbc8\", title: \"忘年会の企画\" },
    { uuid: \"7549c8f8-3af5-4b70-b334-3d4c81480821\", title: \"サンタさんにお手紙\" },
  ]"
}

// データの取り出し
const loadedTaskList: Task[] = JSON.parse(localStorage.getItem("taskData"));

setItem(key,value)が保存、getItem(key)が取り出しです。
⚠️ブラウザに蓄積されているデータを消すとlocalStorageのデータが消えるので注意してください
スクリーンショット 2025-12-09 19.14.15.png
画面はChromeの場合

データの保存

それではlocalStorageを使ったデータの保存と読み込みをアプリに追加していきましょう。
まずはデータの保存部分を追加します。

App.tsx
import { useEffect } from 'react';
...

const App = () => {
  ...

  useEffect(() => {
    localStorage.setItem("taskData", JSON.stringify(taskList));
  }, [taskList]);
  
  ...
};

ここでReactの機能の一つ、useEffectを使います。
useEffect(f(x),[hogehoge])は、引数に関数と配列がセットになっています。
※上記のパターンではこの形に見えにくいですが、(引数) => {処理}の形で書いているものは名前がなくても関数として扱われます。
() => {localStorage.setItem("taskData", JSON.stringify(taskList))}の部分が関数にあたります。

useEffectは第二引数の配列に入っている値をいつも監視していて、この値に何か変更があったら第一引数の関数を実行します。
useEffect(f(x),[hogehoge])は「hoeghogeに何かあったらf(x)を実行するよ!」という意味になります。
今回のケースではuseEffecttaskListを監視していて、taskListに何か変化があったらデータの保存を実行するようにしています。

データの読み込み

次にデータの読み込み部分です。

App.tsx
const App = () => {
  const defaultTaskList: Task[] = [
	{ uuid: crypto.randomUUID(), title: "年末調整" },
	{ uuid: crypto.randomUUID(), title: "忘年会の企画" },
	{ uuid: crypto.randomUUID(), title: "サンタさんにお手紙" },
  ];
  const loadedTaskList: Task[] = JSON.parse(localStorage.getItem("taskData") || "[]");
  const initialTaskList: Task[] = loadedTaskList.length > 0 ? loadedTaskList : defaultTaskList;
  ...
};

localStorageから"taskData"というキーの値がないか探して、あったらそれを、なかったら空の配列を読み込み、JSON.parseで保存時の形から使える形に戻します。
もしデータがなかったらdefaultTaskListを代わりに使います。
これで保存も読み込みも作れてめでたし...といきたいところでしたが、リロードするとまだ値が元に戻ってしまいます。

関数の貸し借り

実は、TaskCardコンポーネントに渡ってきたtaskを書き換えても、Appに用意した保存の処理は動きません。
useEffectは監視しているオブジェクト(今回はtaskList)全体が新しいオブジェクトに置き換わらないと動かないからです。

setTaskList([新しいtaskList配列])のように全く新しい配列に置き換えるなら動きますが、taskList.push(新しいtask)のように中身を変更するだけの処理では動きません。配列全体が新しいものになっている必要があります。useEffectの監視は中身の値まで一つひとつ見ているわけではなく、丸ごと置き換わってないかどうかだけをチェックしています。

TaskCardは受け取ったtaskを自分のuseStateの中でいじっているだけなので、taskListオブジェクト全体には影響を及ぼせていません。

ならば、TaskCardコンポーネントにデータ上書きの処理を書いてみるというのはどうでしょうか。
実は「taskの保存」はtaskList全体に影響するかなり強い操作です。
TaskCardコンポーネントの仕事は自分のtask一個だけが持ち場なので、だいぶ権限オーバーです。
taskList全体を管理するのはAppの仕事なので、TaskCard独断で保存するのではなくAppに話を通してほしいです。
そうはいっても実際に入力を受け取っているのはTaskCardなので、TaskCardは困ってしまいました。

そこで、「関数の貸し借り」を使ってこの問題を解決していきます。

まずはAppでtaskListを管理するuseStateを用意します。

App.tsx
...
import { useEffect, useState } from 'react';

const App = () => {
  ...
  
  const initialTaskList: Task[] = ...
  const [taskList, setTaskList] = useState(initialTaskList);
  
  ...
};

次にApp側でtaskListを書き換える関数を用意します。
IDをチェックして、編集されたタスクと同じIDだったらそのタスクを元のものと取り替えます。
AppはtaskList全体の管理を担当しているので、このような関数を持っていても自然なことです。

App.tsx
...
const App = () => {
  ...
  const [taskList, setTaskList] = useState(defaultTaskList);

  const handleEditTitle = (editedTask: Task) => {
    setTaskList(
      taskList.map((task) => (
        task.uuid == editedTask.uuid ? editedTask : task
      ));
    );
  };
  ...
};

さて、新しいタスクを入れると古いタスクと入れ替えてくれる関数handleEditTaskができました。
この関数handleEditTaskをTaskCardに渡します
するとtaskListそのもののデータは渡さないままに、taskListを書き換える権利だけをTaskCardが使えるようになります。
TaskCardは「保存したいです!」とAppに伝え、Appが保存を実行する関係性が生まれたのです。

App.tsx
...
const App = () => {
  ...
  const handleEditTitle = ...

  return (
    <>
      {taskList.map((task) => (
        <TaskCard 
          key={task.uuid}
          task={task}
          handleEditTitle={handleEditTitle}
        />
      ))}
    </>
  );
};

次はTaskCard側で関数を受け取る準備を整えましょう。
まずはTaskCard側の実はいらなかった処理を消していきます。

TaskCard.tsx
- import { useState } from 'react';
...
export const TaskCard = ( {task}: TaskCardProps ) => {
- const [currentTask, setCurrentTask] = useState(task);
-
- const onEditTask = (editedTask: Task) => {
-   setCurrentTask(editedTask);
- };
  ...
};

PropsにhandleEditTaskを追加します。
関数をPropsに入れるときの型は(引数の型) => 戻り値の型という書き方をします。

TaskCard.tsx
type TaskCardProps = {
  task: Task;
  handleEditTitle: (editedTask: Task) => void;
};
...

onChangeの中で借りた関数を呼び出しましょう!

TaskCard.tsx
- export const TaskCard = ( {task}: TaskCardProps ) => {
+ export const TaskCard = ( {task, handleEditTitle}: TaskCardProps ) => {
   return (
      <div>
        <input
-         value={currentTask.title}
+         value={task.title}
          onChange={(e) => {
-           onEditTask({
-             ...currentTask,
+           handleEditTitle({
+             ...task,
              title: e.currentTarget.value
            })
          }}
        />
      </div>
  );
};
  1. TaskCardに何か書き込むと、inputタグのonChangeが反応
  2. onChangeがAppから借りたhandleEditTitleを呼び出す
  3. handleEditTitlesetTaskListを呼び出しtaskListを更新
  4. taskListの更新を検知したuseEffectが新しいtaskListを保存

という流れが作られて、リロードしても書いた内容が保存されるようになりました!

タスクの追加

タスクを保存できるようになったので、新しいタスクを追加できるようにしていきます。
+と書かれたボタンを用意します。

<button>+</button>

ボタンが押された時に呼ばれる関数を用意します

const handleAddTask = () => {};
<button onClick={handleAddTask}>+</button>

ここに新しいタスクを追加する処理を書いて完成です

const handleAddTask = () => {
  setTaskList(taskList.concat({ uuid: crypto.randomUUID(), title: '' }));
};
<button onClick={handleAddTask}>+</button>

Appに組み込みます。

App.tsx
...
const App = () = {
  ...
  const handleAddTask = () => {
    setTaskList(taskList.concat({ uuid: crypto.randomUUID(), title: '' }));
  };

  return (
    <>
      <button onClick={handleAddTask}>+</button>
      {taskList.map((task: Task) => (
        <TaskCard
          key={task.uuid}
          task={task}
          handleEditTitle={handleEditTitle}
        />
      ))}
    </>
  );
};

add_root_task.png

+ボタンをクリックすると新しいタスクが追加されます。

タスクの削除

次はタスクの削除を作ります。
追加と同じように「done」と書かれたボタンを用意します。
タスクの追加と違うのは、追加がタスクリスト全体に対しての追加だったのに対して 削除は各タスクの横にボタンが欲しいところです。
なので、doneボタンはTaskCard側に準備します。

カードを削除するという処理は、taskList全体に影響の出る処理です(配列taskListの要素を1つ消すということなので)。
TaskCardは先ほどtaskListを保存できなくて困ったように、ここでも自分の権限を超えた操作(taskList全体に対する操作)を要求されて困ってしまいます。
解決方法は同じです。
Appからタスクを消す関数を借りましょう。
Appでタスクを削除する関数handleDeleteTaskを作って、TaskCardのPropsに渡します。

App.tsx
...
const App = () => {
  ...
  const handleDeleteTask = (deletedTask: Task) => {
    setTaskList(
      taskList.filter((task) => task.uuid != deletedTask.uuid);
    );
  };

  return (
    <>
      <button onClick={handleAddTask}>+</button>
      {taskList.map((task: Task) => (
        <TaskCard
          key={task.uuid}
          task={task}
          handleEditTitle={handleEditTitle}
          handleDeleteTask={handleDeleteTask}
        />
      ))}
    </>
  );
};

受け取るTaskCard側もまずPropsにhandleDeleteTaskを追加します。

TaskCard.tsx
type TaskCardProps = {
  task: Task;
  handleEditTitle: (editedTask: Task) => void;
  handleDeleteTask: (deletedTask: Task) => void;
};

export const TaskCard = (
  {task, handleEditTitle, handleDeleteTask}: TaskCardProps
) => {
  return (
    <div>
      <input
        value={task.title}
        onChange={(e) => {
          handleEditTitle({ ...task, title: e.currentTarget.value })
        }}
      />
      <button onClick={(_e) => handleDeleteTask(task)}>
        done
      </button>
    </div>
  );
};

buttonタグのonClick(クリックされた時に中身を実行)に借りてきた関数を入れます。

complete_task.png
doneをクリックしてタスクを削除できるようになりました!

タスクの並び替え

タスクの並び替えはドラッグ&ドロップでやりたいです。
ところが、ドラッグ&ドロップを実装するのはなんだかとても難しそうです。
こんな時は、ライブラリの力を借りましょう。

dnd-kitはドラッグ&ドロップを扱うライブラリで、これを入れればドラッグ&ドロップが実装できるようになります。

ターミナルで以下のコマンドを実行してインストールします(筆者はpnpmを使っています。環境に応じてnpmやyarnなどに置き換えてください)。

pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

dnd-kitで並び替えをやるのに必要なのは、SortableContextです。
SortableContextによって囲まれた領域にあるコンポーネントは並び替えができるようになります。

App.tsx
import {
  SortableContext,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';

const App = () = {
  ...
  return(
    <>
      ...
      <SortableContext
        items={taskList.map((task) => task.uuid)}
        strategy={verticalListSortingStrategy}
      >	
        {taskList.map((task: Task) => (
          ...
        ))}
      </SortableContext>
      ...
    </>
  );
};

今回並び替えをやりたいのはtaskListなので、taskListの描画部分をSortableContextで囲います。
SortableContextの引数のitemsはアイテムの並び順、strategyはアイテムの並び方(横とか縦とか)を決めています。

次に、ドラッグ&ドロップの操作を受け付ける空間を作ります。
dnd-kitでドラッグ&ドロップを受け付ける空間を作れるのは、DndContextです。
DndContextによって囲まれた領域にあるコンポーネントはドラッグ&ドロップができるようになります。

App.tsx
import {
  closestCenter,
  DndContext,
  type DragEndEvent,
  DragOverlay,
  type DragStartEvent,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  ...
  sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';

const App = () = {
  ...
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );

  return(
    <>
      ...
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}  
      >
        <SortableContext
          items={taskList.map((task) => task.uuid)}
          strategy={verticalListSortingStrategy}
        >
          ...
        </SortableContext>
      </DndContext>
    </>
  );
};

SortableContextをさらにDndContextで囲います。
sensors,collisionDetectionはドラッグの当たり判定などに必要な引数で、dnd-kitが用意してくれている値を使います。
onDragStartonDragEndはそれぞれドラッグの開始時と終了時に呼び出される関数です。

ドラッグの開始時 -> 今ドラッグしているタスクの取得
ドラッグの終了時 -> 離した場所に応じてタスクの順番を入れ替え

それぞれこうした処理が要求されますが、ここは少々自力実装する必要があります。

App.tsx
import {
  ...
  arrayMove,
} from '@dnd-kit/sortable';

const App = () = {
  ...
  const [activeId, setActiveId] = useState<string | null>(null);

  const handleDragStart = (event: DragStartEvent) => {
    setActiveId(event.active.id as string);
  };

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (over && active.id !== over.id) {
      const oldIndex = taskList.findIndex((item) => item.uuid === active.id);
      const newIndex = taskList.findIndex((item) => item.uuid === over.id);
      const newTaskList = arrayMove(taskList, oldIndex, newIndex);
      setTaskList(newTaskList);
    };
    setActiveId(null);
  };

  const activeItem = activeId
    ? taskList.find((task) => task.uuid === activeId)
    : null;

  return ( ... );
};

activeItemactiveIdは今まさにドラッグしている最中のカードのことです。
useStateを使って今まさにドラッグしているカードのIDを保持しています。
handleDragStartでドラッグし始めたカードをactiveにして、handleDragEnd
①離した場所にあるカードのIDを取得
②今ドラッグ中のカードと違ったらtaskListの順番を入れ替える
③activeでなくする
処理をしています。

activeになっている(ドラッグ中の)カードの見た目をちょっと変えてわかりやすくします。
まずはactiveになっているカード用のコンポーネントを準備します。

App.tsx
const OverlayTask = ({uuid, title}: Task) => {
  return(
    <div>
      <input value={title}/><button disabled>done</button>
    </div>
  );
};

dnd-kitのDragOverlayを使うと、ドラッグしている時だけ表示される要素を作れます。
置く場所はDndContextの下、SortableContextの隣です。

App.tsx
import {
  ...
  DragOverlay,
} from '@dnd-kit/core';

const OverlayTask = ...

const App = () => {
  ...
  return (
    <>
      <button onClick={handleAddTask}>+</button>
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
      >
        <SortableContext
          items={taskList.map((task) => task.uuid)}
          strategy={verticalListSortingStrategy}
        >
          ...
        </SortableContext>
        <DragOverlay>
          {activeItem ? (
            <OverlayTask {...activeItem} />
          ) : null }
        </DragOverlay>
      </DndContext>
    </>
  );
};

Reactは本来、「画面に何を表示するか(データ)」を管理するのは得意ですが、「それが画面の具体的にどの座標(X, Y)にあるか」という物理的な情報は持っていません。
しかし、ドラッグ&ドロップ機能を実現するには、「マウスが今どの要素の上にあるか」「要素の幅は何ピクセルか」といった、ブラウザ上のリアルな位置情報が必要です。
そこでrefの出番です。 refを使うと、Reactの管理下にある仮想的な要素ではなく、ブラウザ上に描画された「実物のDOM要素」 に直接アクセスできるようになります。
これにより、ライブラリが座標計算を行えるようになるのです。

App.tsx
...
const App = () => {
  ...
  
  const containerRef = useRef<HTMLDivElement>(null);

  return (
    <>
      <button onClick={handleAddTask}>+</button>
      <div ref={containerRef}>
        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragStart={handleDragStart}
          onDragEnd={handleDragEnd}
        >
          ...
        </DndContext>
      </div>
    </>
  );
};

これでApp側のドラッグ&ドロップに必要な作業は完了です。

次にTaskCardをドラッグに対応できるようにしていきます。
TaskCard側にはまず、ドラッグするための取っ手を用意します。
font-awesomeライブラリを入れるなり、SVGを用意するなりしてドラッグする部分のアイコンを用意します。
一例として、SVGを置いておきます。

Hamburger.tsx
import type { ComponentProps } from 'react';

export const Hamburger = ({ ...props }: ComponentProps<'svg'>) => {
  return (
    <svg viewBox="0 0 12 12" aria-hidden="true" {...props}>
      <g fill="none">
        <rect height="12" width="12" fill="none" />
        <line
          stroke="currentColor"
          strokeLinecap="round"
          strokeWidth="1.5"
          x1="0.797"
          x2="11.199"
          y1="1.732"
          y2="1.732"
        />
        <line
          stroke="currentColor"
          strokeLinecap="round"
          strokeWidth="1.5"
          x1="0.797"
          x2="11.199"
          y1="5.982"
          y2="5.982"
        />
        <line
          stroke="currentColor"
          strokeLinecap="round"
          strokeWidth="1.5"
          x1="0.797"
          x2="11.199"
          y1="10.23"
          y2="10.23"
        />
      </g>
    </svg>
  );
};

これをTaskCardの横に置いて、ドラッグの目印にします。

TaskCard.tsx
+ import { Hamburger } from './Hamburger';

...

export const TaskCard = (
  {task, handleEditTitle, handleDeleteTask}: TaskCardProps
) => {
  return (
    <div>
+     <Hamburger width={13} height={13} />
      <input
        value={task.title}
        onChange={(e) => {
          handleEditTitle({ ...task, title: e.currentTarget.value })
        }}
      />
      <button onClick={(_e) => handleDeleteTask(task)}>
        done
      </button>
    </div>
  );
};

次にこのアイコンをドラッグを受け付けるようにしていきます。

TaskCard.tsx
...
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

...

export const TaskCard = (
  {task, handleEditTitle, handleDeleteTask}: TaskCardProps
) => {
  const {
    attributes,
    listeners,
    setNodeRef,
	transform,
    transition,
    isDragging,
  } = useSortable({ id: task.uuid });

  const draggingStyle = {
    transform: CSS.Transform.toString(transform),
    transition: isDragging ? 'none' : transition,
    visibility: isDragging ? ('hidden' as const) : ('visible' as const),
  };

  return (
    <div
      key={task.uuid}
      style={draggingStyle}
      ref={setNodeRef}
    >
	  <div {...attributes} {...listeners}>
        <Hamburger width={13} height={13} />
      </div>
	  ...
	</div>
  );
};

draggingStyleはドラッグ中の見た目を制御するためのもので、attributeslistenersがドラッグを受け付けるための属性です。
スクリーンショット 2025-12-04 19.09.31.png

だいぶ表示が変になってきているのでscssでちょっと整えます。
(scssが入っていなかったらインストールが必要です)

TaskCard.module.scss
.taskCard {
  display: flex;
  align-items: center;
}

.dragHandle {
  padding: 4px;
}
TaskCard.tsx
...
+ import styles from './TaskCard.module.scss';

...

export const TaskCard = (
  {task, handleEditTitle, handleDeleteTask}: TaskCardProps
) => {
  ...
  return (
-    <div style={draggingStyle} ref={setNodeRef}>
+    <div style={draggingStyle} className={styles.taskCard} ref={setNodeRef}>
-       <div {...attributes} {...listeners}>
+	    <div className={styles.dragHandle} {...attributes} {...listeners}>
          <Hamburger width={13} height={13} />
        </div>
	    ...
	  </div>
  );
};

sortable.gif
ついに並び替えが実装できました!

タスクの分割

タスクの分割については「どうやって表現するか?」から考えていく必要がありそうです。
分割したタスクと分割前のタスクの関係性を考えると、分割前のタスクから分割後のタスクが複数生まれる可能性がある一方、分割後のタスクには必ず分割前のタスクが存在していることになります。
もちろん、分割しないという可能性もあります。
図にすると...
image.png
ということは、タスクにそれぞれ自分の親を持ってもらうのが管理しやすそうです。
せっかく一意のIDを振ってるので、親のIDを持っておくことにします。
親がいないタスクはとりあえず"root"としておきましょう

TaskCard.tsx
export type Task = {
	uuid: string;
	title: string;
+	parentId: string;
};

これを元に、いい感じの三階層データを準備します。

App.tsx
const parentIdExample = crypto.randomUUID();
const middleIdExample = crypto.randomUUID();
const defaultTaskList: Task[] = [
  { uuid: crypto.randomUUID(), title: "年末調整", parentId: "root" },
  { uuid: parentIdExample, title: "忘年会の企画", parentId: "root" },
  { uuid: crypto.randomUUID(), title: "サンタさんにお手紙", parentId: "root" },
  { uuid: middleIdExample, title: "店の予約", parentId: parentIdExample },
  { uuid: crypto.randomUUID(), title: "イベント企画", parentId: parentIdExample },
  { uuid: crypto.randomUUID(), title: "店を決める", parentId: middleIdExample },
  { uuid: crypto.randomUUID(), title: "日程調整", parentId: middleIdExample },
];

階層構造をわかりやすくするとこうです。

- 年末調整
- 忘年会の企画
    - 店の予約
        - 店を決める
        - 日程調整
    - イベント企画
- サンタさんにお手紙

Task型にparentIdを追加したので、Task型を使っているところをチェックしてparentIdを追加します。
まずはデータの読み込み処理です。
親タスクを消しても子タスクが内部的に生き残っていたりするので、一番親のタスクがあるかどうかだけチェックするようにします。
(本当は親タスクを消す時に子タスクを再帰的に全消しする方がスマートです)

App.tsx
...
const App = () = {
  ...
  const initialTaskList: Task[]
-   = loadedTaskList.length > 0 ? loadedTaskList : defaultTaskList;
+   = loadedTaskList.filter((task) => task.parentId == "root").length > 0 ? loadedTaskList : defaultTaskList;
  ...
  };
};

タスクの追加処理にもparentIdを追加します。

App.tsx
...
const App = () = {
  ...
  const handleAddTask = () = {
-   setTaskList(taskList.concat({ uuid: crypto.randomUUID(), title: '' }));
+   setTaskList(taskList.concat({ uuid: crypto.randomUUID(), title: '', parentId: "root" }));
  };
  ...
};

階層構造の表示

次にTaskCardの表示方法について考えます。
・親カードの右下に子カードがくっついている形にしたい
・親カードを移動させたら、一緒に子カードもくっついてきて欲しい
という要件を思いつきました。
要件を満たすには、TaskCardに自分の子タスクの表示までやってもらうのがよさそうです。
コンポーネントは自分自身と同じコンポーネントを呼び出すことも普通にできるので、再帰的に書くことができます。
では、書いてみましょう。

...

おっと、困りました。
TaskCardは自分の親のIDは知っていても自分の子を知りません。
もちろん、taskList全部を渡せばその中から探すことはできるのですが...
前回も困ったように、TaskCardが保持しているデータは割り当てられたtaskに限定したいです。
書き換えたりする仕事ではないのでTaskCardにtaskList全部を渡してもダメではないのですが、長い目でみるとtaskList全部の情報がないと動かないコンポーネントとtask一個あれば動けるコンポーネント、どっちがより使い回しやすい「良いコンポーネント」かと考えれば後者になりそうです。

TaskCardが自分の子タスクを取得するのが難しい一方、Appは簡単に特定のタスクの子タスクを取得することができます。
これまで関数を貸してきたように、子タスクを取得する関数をAppに定義して、TaskCardに貸してあげるのはどうでしょう。

引数:親のID
戻り値:そのIDを親に持つ子タスクの配列

この要件を満たす関数getChildrenを作り、できたらTaskCardに渡します。

App.tsx
...
const App = () => {
  ...
  const getChildren = (parentId: string) => {
    return taskList.filter((task) => task.parentId == parentId);
  };

  return (
    <>
      ...
      <TaskCard
        key={task.uuid}
        task={task}
        getChildren={getChildren}
        handleEditTitle={handleEditTitle}
        handleDeleteTask={handleDeleteTask}
      />
      ...
    </>
  );
};

TaskCard側はPropsにgetChildrenを追加して、getChildrenで受け取った子タスクの配列にそれぞれTaskCardコンポーネントを割り当てていきます。
子タスクも自分と同じ関数を使うので、自分がAppから借りた関数を又貸ししましょう。

TaskCard.tsx
type TaskCardProps = {
	...
	getChildren: (parentId: string) => Task[];
};

export const TaskCard = (
  {task, getChildren, handleEditTitle, handleDeleteTask}: TaskCardProps
) => {
  ...
  return (
    <div style={draggingStyle} ref={setNodeRef}>
      ...
      {getChildren(task.uuid).map((child: Task) => (
        <TaskCard 
          key={child.uuid}
          task={child}
          getChildren={getChildren}
          handleEditTitle={handleEditTitle}
          handleDeleteTask={handleDeleteTask}
        />
      ))}
    </div>
  );
};

これで各TaskCardが自分の子タスクを表示できるようになっているはずです。

スクリーンショット 2025-12-04 15.13.00.png
おおっと、大変なことになっています。
Appの一番親のタスクを表示する処理が全部のタスクを表示しているため、こうなっています。
子タスクの表示はそれぞれのTaskCardに任せているので、AppではparentId"root"になっているものしか表示したくないです。
それでは、taskListの中から、parentId"root"になっているものに絞込みをかけて...

App.tsx
...
const App = () => {
  ...
  return(
    ...
-   {taskList..map((task: Task) => (
+   {taskList.filter((task) => task.parentId == "root").map((task: Task) => (
      <TaskCard
        key={task.uuid}
        task={task}
        getChildren={getChildren}
        handleEditTitle={handleEditTitle}
        handleDeleteTask={handleDeleteTask}
      />
    ))}
    ...
  );
};

🌸🖐️ちょっと待って!getChildrenparentIdでタスクを絞り込む関数だから、getChildrenの引数に"root"を渡してあげればいいんじゃないかな?

App.tsx
...
const App = () => {
  ...
  return(
    ...
-   {taskList.filter((task) => task.parentId == "root").map((task: Task) => (
+   {getChildren("root").map((task: Task) => (
      <TaskCard
        key={task.uuid}
        task={task}
        getChildren={getChildren}
        handleEditTitle={handleEditTitle}
        handleDeleteTask={handleDeleteTask}
      />
    ))}
    ...
  );
};

TaskCardが子タスクを表示する処理とそっくりに書けました!
なんとなく気分が良いですね。

スクリーンショット 2025-12-04 15.52.35.png
あとは親タスクと子タスクに別々のスタイルを当てて見やすくしましょう。
まず、子タスクはちょっと右にそれてもらいます。

TaskCard.module.scss
...
+ .children {
+   padding-left: 20px;
+ }

そして、親タスクのスタイルと子タスクのスタイルを分けます。

TaskCard.tsx
...
export const TaskCard = (
  ...
  return (
-   <div style={draggingStyle} className={styles.taskCard} ref={setNodeRef}>
+   <div style={draggingStyle} ref={setNodeRef}>
+     <div className={styles.taskCard}>
        <div className={styles.dragHandle} {...attributes} {...listeners}>
          <Hamburger width={13} height={13} />
        </div>
        <input
          value={task.title}
          onChange={(e) => {
            handleEditTitle({ ...task, title: e.currentTarget.value })
          }}
        />
        <button onClick={(_e) => handleDeleteTask(task)}>
          done
        </button>
+     </div>
      {getChildren(task.uuid).map((child: Task) => (
+       <div key={child.uuid} className={styles.children}>
          <TaskCard
            key={child.uuid}
            task={child}
            getChildren={getChildren}
            handleEditTitle={handleEditTitle}
            handleDeleteTask={handleDeleteTask}
          />
+       </div>
	  ))}
	</div>
  );
};

styled_tasktree.png
階層構造がわかりやすい見た目になりました!

子タスクの追加

あとは子タスクを追加する処理を書いていきます!
どのタスクも分割可能にしたいので、分割ボタンは全タスクが持つ、つまりTaskCard側に実装します。
子タスクを追加するということはtaskListに新たにtaskを追加することなので、もちろん権限オーバーです。
例によってApp側に子タスクを追加する関数を用意して、TaskCardに貸します。

App.tsx
...
const App = () => {
  ...
  const handleAddChild = (parentId: string) => {
    setTaskList(taskList.concat({uuid: crypto.randomUUID(), title: '', parentId: parentId }));
  };

  return(
    ...
    {getChildren("root").map((task) => (
      <TaskCard
        key={task.uuid}
        task={task}
        getChildren={getChildren}
        handleEditTitle={handleEditTitle}
        handleDeleteTask={handleDeleteTask}
        handleAddChild={handleAddChild}
      />
    ))}
    ...
  );
};

PropsにhandleAddChildを追加して、受け取ったら+ボタンに割り当てます。
子タスクへの又貸しもします。

TaskCard:tsx
type TaskCardProps = {
	...
	handleAddChild: (parentId: string) => void;
};

export const TaskCard = (
  {task, getChildren, handleEditTitle, handleDeleteTask, handleAddChild}: TaskCardProps
) => {
  ...
  return (
    <div style={draggingStyle} ref={setNodeRef}>
      <div className={styles.taskCard}>
        ...
        <button onClick={(_e) => handleAddChild(task.uuid)}>
          +
        </button>
      </div>
      {getChildren(task.uuid).map((child: Task) => (
        <div key={child.uuid} className={styles.children}>
          <TaskCard
            task={child}
            getChildren={getChildren}
            handleEditTitle={handleEditTitle}
            handleDeleteTask={handleDeleteTask}
            handleAddChild={handleAddChild}
          />
        </div>
      ))}
    </div>
  );
};

スクリーンショット 2025-12-04 19.29.24.png

ついに完成です。
タスクの並び替えに分割、削除とやりたいことは一通りできるようになっています!

🌸🖐️ちょっと待って!子タスクを追加する処理とrootのタスクを追加する処理は共通化できるんじゃないかな?

App.tsx
...
const App = () => {
  ...
- const handleAddTask = () => {
-   setTaskList(taskList.concat({uuid: crypto.randomUUID(), title: '', parentId: "root" }));
- };
  
  const handleAddChild = (parentId: string) => {
    setTaskList(taskList.concat({uuid: crypto.randomUUID(), title: '', parentId: parentId }));
  };

  return (
    <>
-     <button onClick={handleAddTask}>+</button>
+     <button onClick={(_e) => handleAddChild("root")}>+</button>
      ...
    </>
  );
};

できました、今度こそ完成です🫙🔥

さらに先へ...

実はまだまだやりたいことは残っています。

本当はタスクをdoneしたら即削除するんじゃなくて、一旦完了状態にしたいです。

今は親タスクのdoneボタンをうっかり押すと全ての子タスクを道連れに消滅します。
子タスクが全部完了するまで親タスクは完了できないようにしたいです。

階層化された状態で親タスクを並べ替えると暴れ狂っています。これもなんとかしたいですね。

子タスクを畳んだり開いたりもしたいです。

最初から分割されていて欲しいタスクもあります。
プリセットをオプションで選択できるようにしたいですね。

今回はデータをブラウザに保存しましたが、多くのユーザに使ってもらうにはちゃんとしたデータベースに接続し、デプロイする必要もあります。

実は今のデータ構造はナイーブツリーと呼ばれており、なんとアンチパターンに指定されています。
ナイーブな気持ちになりますね。
データ構造も見直す必要がありそうです。

まだまだ改善の余地があって楽しいですね。
手元で使う分に必要な最低限の要件はひとまず満たせているので、ここで一区切りとさせていただきます。

お知らせ

株式会社DONUTSでは、新卒・中途・インターンを問わず一緒に働くメンバーを募集しています。
もし弊社に興味を持っていただけた方はぜひ応募をご検討ください!
全国各地の開発拠点でお待ちしています!

私の所属するジョブカン事業部はこちらです!

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?