Reactで作るTODOアプリ|ドラッグ&ドロップと自動保存


ReactでTODOアプリを作るとき、手が止まるのは追加や削除ではありません。ドラッグでカードを離したときに、どのレーンの何番目へ入れ直すかを自分で決めるところと、その状態をブラウザに残して、次に開いたときも同じ並びで見せるところの2つです。追加・編集・削除そのものは useState だけで足ります。

この記事では、アイデア・進行中・完了の3レーンを持つカンバン型のTODOアプリを、環境構築 → 状態設計 → ドラッグ&ドロップ → 自動保存 → ビルドして公開 の順に、途中を飛ばさず作ります。掲載しているコードは実際に公開しているデモと同じもので、React 19・@hello-pangea/dnd 18・Vite で組み、サブディレクトリへの配置まで通して動作を確認しています。

先に TODOアプリのデモ を触ってから読むと、どのコードがどの挙動に対応しているか掴みやすくなります。JavaScriptだけで小さなアプリを組み立てる流れは JavaScriptで作るミニアプリ4選 に、同じ「閉じても消えない」をTypeScriptで実装したものは TypeScriptで作るチェックリストアプリ にまとめてあります。


作るもの:3レーンのカンバン型TODO

完成するアプリの機能は次の5つです。

  • 入力欄からタスクを追加すると、いちばん左の「アイデア」レーンに積まれる
  • カード左のハンドルをつかんで、レーンをまたいでドラッグできる。同じレーン内での並べ替えもできる
  • カードごとに編集・削除ができる。編集はEnterで確定、Escでキャンセル
  • レーンの見出しに、そのレーンの件数が出る
  • 操作のたびにブラウザへ自動保存され、リロードしても閉じても復元される

使うものと、それぞれの役割は次のとおりです。

使うものこの記事での役割
React 19画面の状態管理。タスクの配列を1つ持ち、そこから3レーンを描画する
@hello-pangea/dnd 18ドラッグ&ドロップ。掴む・並べ替える・別のレーンへ落とす、の操作を引き受ける
Vite開発サーバーと本番ビルド。サブディレクトリへ置くときのパス解決もここで指定する
Web Storage API(localStorage)タスクの永続化。文字列しか保存できないので JSON.stringify / JSON.parse を挟む

サーバーもデータベースも使いません。保存先はブラウザの中だけなので、別の端末やシークレットウィンドウには引き継がれない、という前提で設計します。


環境構築:create-react-appではなくViteを使う

Reactの入門記事では長らく npx create-react-app が使われてきましたが、Create React App は2025年2月14日に非推奨になりました。React公式ブログのSunsetting Create React Appでは「新しいアプリではCreate React Appを非推奨とし、既存のアプリはフレームワークか、Vite・Parcel・Rsbuildのようなビルドツールへの移行を推奨する」と明記されています。現在は実行しても非推奨の警告が出ます。

この記事では、単体で完結する小さなアプリなのでビルドツール側のViteを選びます。プロジェクトの作成は次の3コマンドです。

npm create vite@latest todo-app -- --template react
cd todo-app
npm install @hello-pangea/dnd

ドラッグ&ドロップのライブラリに@hello-pangea/dndを選ぶ理由

Reactのドラッグ&ドロップでは長く react-beautiful-dnd が定番でしたが、こちらも現在は使えません。GitHubリポジトリはアーカイブ済みで、「このプロジェクトはアーカイブされ、npm上で非推奨になりました」と告知されています(2025年8月18日)。npmの最終公開は 13.1.1(2022年8月30日)で止まっており、インストールすると非推奨の警告が出ます。

@hello-pangea/dnd は、その react-beautiful-dnd をコミュニティが引き継いだフォークです。APIはほぼそのままなので、古い記事のコードがだいたい読み替えなしで動きます。npmの最新は 18.0.1 で、peerDependencies は React 18 と 19 の両方を受け付けます。

ライブラリ状態この記事での扱い
react-beautiful-dndアーカイブ済み・npmで非推奨。最終版 13.1.1(2022年8月)使わない。古い解説記事はこちらを前提にしていることが多い
@hello-pangea/dnd上記のフォーク。18.0.1 が最新でReact 18/19対応これを使う。リスト間の移動に強く、キーボード操作にも対応している
Pragmatic drag and dropreact-beautiful-dnd の作者側が案内している後継できることは広いが設計の自由度が高く、3レーンのカンバンには過剰

状態設計:タスクは1つの配列で持ち、レーンはstatusで決める

最初の分かれ道が、レーンごとに3つの配列を持つか、タスクを1つの配列にまとめて、どのレーンにいるかを各タスクの status で表すかです。この記事では後者にします。

// タスク1件の形。status がそのままレーンになる
{ id: "8f1c...", text: "記事の構成を決める", status: "idea" }

3つの配列に分けると、レーン間の移動のたびに「片方から抜いて、もう片方に足す」を2つの状態にまたがって行うことになり、片方だけ更新に失敗した状態が作れてしまいます。1つの配列なら、移動は該当タスクの status を書き換えるだけで、画面はそこから絞り込んで描くだけになります。保存も配列1本をそのまま書き出せば済みます。

レーンの並び順は定数として1か所に置き、描画も保存の検証もこれを参照します。

export const STATUSES = ["idea", "inProgress", "done"];

const LANE_LABELS = {
  idea: "アイデア",
  inProgress: "進行中",
  done: "完了",
};

idcrypto.randomUUID() で作ります。配列の添字やタスク名を id の代わりにすると、並べ替えたときや同じ文言のタスクを作ったときにドラッグの対象が入れ替わります。ライブラリ側も draggableId に一意な文字列を要求するので、ここは最初から一意なIDにしておきます。


ドラッグ&ドロップを組む

@hello-pangea/dnd は3つの入れ子で構成します。役割は次のとおりです。

コンポーネント役割
DragDropContextドラッグ全体を囲む。ドロップが終わった瞬間に onDragEnd が呼ばれる
Droppable落とせる場所=レーン。droppableId にレーンの名前を渡す
Draggable掴めるもの=カード。draggableId と、そのレーン内での index を渡す

描画は「タスクの配列をレーンごとに絞り込んで並べる」だけです。index には配列全体での位置ではなく、絞り込んだ後のレーン内での位置を渡します。ここを間違えると移動先の計算がずれます。

<DragDropContext onDragEnd={onDragEnd}>
  <div className="container">
    {STATUSES.map((status) => {
      const laneTodos = todos.filter((todo) => todo.status === status);
      return (
        <Droppable key={status} droppableId={status}>
          {(provided, snapshot) => (
            <section
              className={`todo-list${snapshot.isDraggingOver ? " is-dragging-over" : ""}`}
              ref={provided.innerRef}
              {...provided.droppableProps}
            >
              <h2 className="todo-list-title">
                {LANE_LABELS[status]}
                <span className="todo-list-count">{laneTodos.length}</span>
              </h2>

              {laneTodos.map((todo, index) => (
                <Draggable key={todo.id} draggableId={todo.id} index={index}>
                  {(dragProvided) => (
                    <div
                      className="todo-item"
                      ref={dragProvided.innerRef}
                      {...dragProvided.draggableProps}
                    >
                      <div className="todo-item-header">
                        <div className="drag-handle" {...dragProvided.dragHandleProps}>
                          ⋮⋮
                        </div>
                        <div className="todo-item-text">{todo.text}</div>
                      </div>
                    </div>
                  )}
                </Draggable>
              ))}

              {provided.placeholder}
            </section>
          )}
        </Droppable>
      );
    })}
  </div>
</DragDropContext>

provided.placeholder は必ず書きます。ドラッグ中のカードは実際の位置から浮くため、これが無いとレーンの高さが縮んで、掴んだ瞬間に下のカードがガクッと動きます。

dragHandleProps をカード全体ではなく専用のハンドル要素に渡している点も意図があります。カード全体を掴めるようにすると、カード内のボタンやテキスト選択とドラッグが競合します。掴む場所を限定すると、編集ボタンを押したいだけなのにカードが動き出す事故がなくなります。


onDragEndの落とし穴:レーンをまたぐと他のレーンの並びが崩れる

ここがこのアプリで唯一こみ入った部分です。実際、このデモの旧実装には別のレーンへ移すと、無関係なレーンの並び順まで変わってしまう不具合がありました。原因は、移動後の配列を組み立てるときに、関係しないレーンのタスクを「元の順番のまま」戻していなかったことです。

壊れやすいのは、配列全体に対して抜き差しをしようとする書き方です。

// うまくいかない書き方:配列全体の位置と、レーン内の位置を混同している
const updated = [...todos];
const movedIndex = updated.findIndex((t) => t.id === draggableId);
const moved = { ...updated[movedIndex] };
updated.splice(movedIndex, 1);

moved.status = destination.droppableId;
// destination.index は「移動先レーン内での位置」なのに、配列全体の位置として使っている
updated.splice(destination.index, 0, moved);

destination.index は移動先レーンの中での位置です。全体の配列にそのまま差し込むと、他のレーンのタスクをまたいで割り込むことになり、レーンごとに絞り込んで描画した結果の並びが変わってしまいます。

確実なのは、いったんレーンごとに分解し、移動元から抜いて移動先の指定位置へ差し込み、また1本に戻す手順です。関係しないレーンには一切触れないので、他のレーンの並びは原理的に変わりません。

const onDragEnd = ({ source, destination, draggableId }) => {
  // レーンの外で離した
  if (!destination) return;
  // 同じ場所に戻した
  if (source.droppableId === destination.droppableId && source.index === destination.index) return;

  setTodos((prev) => {
    const moved = prev.find((todo) => todo.id === draggableId);
    if (!moved) return prev;

    // 1. レーンごとに分解する
    const lanes = Object.fromEntries(
      STATUSES.map((status) => [status, prev.filter((todo) => todo.status === status)])
    );

    // 2. 移動元から抜く
    lanes[source.droppableId] = lanes[source.droppableId].filter(
      (todo) => todo.id !== draggableId
    );

    // 3. 移動先の指定位置へ、statusを書き換えて差し込む
    lanes[destination.droppableId] = [
      ...lanes[destination.droppableId].slice(0, destination.index),
      { ...moved, status: destination.droppableId },
      ...lanes[destination.droppableId].slice(destination.index),
    ];

    // 4. レーンの順番どおりに1本へ戻す
    return STATUSES.flatMap((status) => lanes[status]);
  });
};

同じレーン内での並べ替えも、別のレーンへの移動も、この1つの流れで処理できます。同じレーンの場合は手順2と手順3が同じ配列に対して行われるだけで、分岐を書き分ける必要はありません。

冒頭の2つの早期リターンも省略できません。レーンの外で離すと destinationnull になり、同じ位置に戻したときに処理を続けると、意味のない再レンダリングと保存が毎回走ります。


閉じても消えないようにする(localStorage)

ここまでの実装は、リロードするとタスクが全部消えます。useState はメモリ上の値なので当然ですが、TODOアプリとしては使い物になりません。localStorage に保存して、起動時に読み戻します。

保存は「変更のたびに配列をまるごと書き出す」だけで足ります。差分を管理する必要はありません。useEffect の依存配列に todos を入れれば、追加・編集・削除・ドラッグのどれで変わっても同じ処理が走ります。

const [todos, setTodos] = useState(loadTodos);

// 変更のたびにブラウザへ保存する
useEffect(() => {
  saveTodos(todos);
}, [todos]);

useState(loadTodos) と、関数をそのまま渡している点に意味があります。useState(loadTodos()) と書くと、再レンダリングのたびに localStorage の読み出しが走ります。関数を渡せば初回だけ実行されます。

復元側は「壊れた値が入っている前提」で書く

保存処理より神経を使うのが読み込み側です。localStorage はユーザーが開発者ツールから書き換えられますし、アプリを更新してデータの形を変えれば、古い形のまま残っている利用者が必ず出ます。JSON.parse() の戻り値は any 相当なので、ここを素通しにすると、次の描画で落ちます

const STORAGE_KEY = "codequest-todo-app";

export function loadTodos() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return [];

    const parsed = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];

    // 形が合っているものだけを通す
    return parsed
      .filter(
        (t) =>
          t &&
          typeof t.id === "string" &&
          typeof t.text === "string" &&
          STATUSES.includes(t.status)
      )
      .map((t) => ({ id: t.id, text: t.text, status: t.status }));
  } catch (error) {
    console.error("保存済みタスクの読み込みに失敗しました:", error);
    return [];
  }
}

export function saveTodos(todos) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
  } catch (error) {
    // 容量超過や、プライベートモードでの書き込み拒否
    console.error("タスクの保存に失敗しました:", error);
  }
}

filterstatusSTATUSES に含まれるかまで見ているのは、知らない値が混ざるとどのレーンにも描画されず、データは残っているのに画面から消えたように見える状態になるからです。落とすと決めたなら、その場で落としてしまうほうが原因を追いやすくなります。

saveTodostry で囲んでいるのは、書き込みが例外を投げることがあるためです。容量の上限に達したときと、ブラウザの設定でストレージが禁止されているときに起きます。ここで例外が外へ出ると、保存に失敗しただけで操作そのものが止まります。保存できる容量の目安は TypeScriptで作るチェックリストアプリ のFAQで扱っています。

なお localStorage はドメイン単位で、しかもプロトコルごとに分かれます。他人と共有する端末や、機密性のあるデータには向きません。どこまで置いてよいかの線引きは localStorageにJWTを置くリスクWeb Storage・Cookie・IndexedDBの使い分け を参照してください。


ビルドしてサブディレクトリに置く

npm run build で静的なHTML・CSS・JSが出力されます。サイトのルート(https://example.com/)に置くならこのままで動きますが、https://example.com/todo-app/ のようなサブディレクトリに置くと、画面が真っ白になります。ビルド結果がアセットをルート基準のパスで参照するためです。

Viteでは base に公開先のパスを書けば解決します。手作業でパスを書き換える必要はありません。

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // 公開先のパス。ルート直下に置くなら "/" のままでよい
  base: "/todo-app/",
  plugins: [react()],
});

これでビルド後の index.html/todo-app/assets/… を参照するようになります。出力されたフォルダの中身をそのままサーバーの同じパスへ置けば動きます。

解説記事によっては、ここで .htaccess にリライトルールを書く手順が出てきます。これが必要になるのは、ページごとにURLを持つルーティングを入れた場合だけです。今回のように画面が1つで、URLが増えないアプリでは不要です。React Routerなどを足して /todo-app/settings のようなURLを直接開けるようにしたときに、はじめて「存在しないパスへのアクセスを index.html に集約する」設定が要ります。


まとめと次の一手

3レーンのTODOアプリで実装の山になるのは、結局この2か所でした。

  1. onDragEnd の並び替え。レーンごとに分解してから差し込み、関係しないレーンには触れない
  2. 復元時の検証JSON.parse の結果を素通しにせず、形が合うものだけを通す

どちらも、動いているように見えて後から壊れる種類の問題です。実装したら、次の3つを実際に操作して確かめてください。レーンをまたいで動かしたあと他のレーンの並びが変わっていないか、リロードして同じ並びで戻るか、開発者ツールで localStorage の値を壊してから開いて白画面にならずに空で立ち上がるかの3つです。

次の一手としては、レーンを増やす(STATUSESLANE_LABELS に足すだけで済みます)、期限や担当者をタスクに持たせる、といった拡張が同じ設計のまま入ります。同じ「保存して閉じても消えない」を型付きで書くとどう変わるかは TypeScriptで作るチェックリストアプリ、ライブラリを使わずDOM操作だけで似たUIを組む場合は カウンターアプリの作り方 が参考になります。

完成品は TODOアプリのデモ でそのまま触れます。Reactを含む学習の進め方全体は JavaScript学習ガイド にまとめてあります。


よくある質問(FAQ)

Q. create-react-appは今も使えますか?

コマンド自体は動きますが、新しく作るアプリでは避けてください。React公式ブログは2025年2月14日に「新しいアプリではCreate React Appを非推奨とし、既存のアプリはフレームワークか、Vite・Parcel・Rsbuildのようなビルドツールへの移行を推奨する」と告知しており、実行すると非推奨の警告が表示されます。この記事のように単体で完結する小さなアプリならViteが素直で、Next.jsやReact Routerのようなフレームワークはルーティングやサーバー側の処理が必要になった段階で検討すれば足ります。

Q. react-beautiful-dndと@hello-pangea/dndはどちらを使うべきですか?

@hello-pangea/dnd です。react-beautiful-dnd はGitHubリポジトリがアーカイブされ、npm上でも非推奨になっており、最終公開は 13.1.1(2022年8月30日)で止まっています。@hello-pangea/dnd はそれをコミュニティが引き継いだフォークで、APIがほぼ同じため古い解説記事のコードもだいたいそのまま動きます。最新は 18.0.1 で、React 18と19の両方に対応しています。

Q. ドラッグで別のレーンに移すと、他のレーンの並び順まで変わってしまいます

destination.index を配列全体の位置として使っているのが原因です。この値は移動先レーンの中での位置なので、全体の配列にそのまま splice で差し込むと、他のレーンのタスクをまたいで割り込みます。いったんレーンごとに分解し、移動元から抜いて移動先の指定位置へ差し込み、最後にレーンの順番どおり1本へ戻してください。関係しないレーンに触れなくなるので、他のレーンの並びは変わりません。

Q. 保存したタスクが別のパソコンで見られないのはなぜですか?

localStorage の保存先がそのブラウザの中だからです。サーバーには何も送っていないため、別の端末はもちろん、同じ端末でも別のブラウザやシークレットウィンドウには引き継がれません。複数の端末で同じタスクを見たい場合は、保存先をサーバー側のデータベースに置き、ログインの仕組みを用意する必要があります。localStorage で完結させるか否かは、この点を許容できるかで決めてください。

Q. 保存が急にできなくなることはありますか?

あります。容量の上限に達したときと、ブラウザの設定でストレージへの書き込みが禁止されているときに、setItem が例外を投げます。この例外を捕まえずに放置すると、保存に失敗しただけで操作そのものが止まってしまうため、保存処理は try で囲み、失敗しても画面の操作は続けられるようにしておきます。

Q. サブディレクトリに置くと画面が真っ白になります

ビルド結果がアセットをルート基準のパスで参照しているためです。Viteなら vite.config.js の base に公開先のパス(例: /todo-app/)を書いてビルドし直せば解決します。出力されたHTMLの script や link のパスを手で書き換える必要はありません。ブラウザの開発者ツールのネットワークタブで、JSとCSSの取得が404になっていないかを確認すると原因を切り分けられます。