🚀 はじめに
私は現在、未経験からエンジニアへの転職を目指して活動しています。学習で得た技術をアウトプットし、アピールするためにポートフォリオとしてTodoアプリを作成しました。本記事では学習手順や使用技術、アプリ概要、実装のポイントなどを紹介します。
アプリ作成時に要件定義や画面遷移図などの設計周りの必要性を知らなかったため、アプリがある程度出来上がった後に作成しています。ですが現状の洗い出しとして効果を感じたのでそちらについても紹介します。
対象読者:
- Next.js+Firebase でCRUD操作のリアルタイム同期アプリを作りたい方
- マルチカラムの並べ替えを作りたい方
- スマホでのドラッグ操作を作りたい方
プロジェクト概要
- アプリ名:List-Board
-
技術スタック:
- フロントエンド:Next.js, React
- スタイリング:Tailwind CSS, Font Awsome
- バックエンド:Firebase Authentication / Firestore
- デプロイ:Vercel
-
主な特徴:
- 未ログイン/匿名ログイン/メールログインの3モード
- リスト・タスクの CRUD & DnD
- リストの共有機能
- 曜日リセット機能
- リアルタイム同期
- レスポンシブ対応
こんなアプリ
デプロイURL:List-Board
Git-Hub:Git-Hub
スクリーンショット
PC画面
スマホ画面
要件定義
詳細は本リポジトリの README の 要件定義 セクションをご覧ください。
データ設計(ER図)
詳細は本リポジトリの README の ER図 セクションをご覧ください。
UI/UX設計(画面遷移図)
Figmaにて作成しました。詳細は本リポジトリの README の 画面遷移図 セクションをご覧ください。
機能紹介と実装ポイント
各機能について、簡単な説明+こだわりポイントや実装時の工夫を書きます。
Todoのリスト&タスク管理
データ構造
執筆時の構成です。
■ List(リスト)
パス:users/{uid}/todos/{listId}
フィールド名 | 型 | 説明 | 実装状況 |
---|---|---|---|
category | string | 所属カテゴリー。初期値は4つ、今後はユーザーの任意のカテゴリーを追加できる機能を実装予定 | 一部済 |
date | string | リストの作成日 | 済 |
locked | boolean | リストのロック状態(true で削除不可) | 済 |
order | number | 並び順の保持 | 済 |
resetDay | Map | 各曜日に対してタスクの進捗を自動リセットするかどうかを指定(例:{ mon: true, tue: false, ... } | 済 |
title | string | リストのタイトル | 済 |
■ Task(タスク)
パス:users/{uid}/todos/{listId}/tasks/{taskId}
フィールド名 | 型 | 説明 | 実装状況 |
---|---|---|---|
content | string | タスクの内容 | 済 |
complete | boolean | チェック済みかどうか | 済 |
order | number | タスクの並び順 | 済 |
編集/削除などのCRUD機能
未ログインユーザーはonSnapShotが使えないので、変更をローカルへ反映するために都度dispatchしています。
ゲスト・ログインユーザーはdispatchと併用してFirebaseのonSnapShotを使い、リアルタイム同期をさせています。アプリ全体でdispatchのみか、スナップショット併用かをif文で分岐させています。
"use client";
import {
createContext,
useContext,
useEffect,
useReducer,
useState,
} from "react";
import { useAuth } from "./AuthContext";
import { subscribeUserTodos } from "@/firebase/todos";
const TodoContext = createContext();
const TodoContextDispatch = createContext();
// 取得・更新・削除を担うReducer
const todoReducer = (state, { type, payload }) => {
switch (type) {
// 初期化
case "todo/init":
return [...payload].sort((a, b) => a.order - b.order);
// リストの追加
case "todo/addList":
return [...state, payload].sort((a, b) => a.order - b.order);
//以下その他CRUD処理
}
};
const TodoProvider = ({ children }) => {
const [state, dispatch] = useReducer(todoReducer, []);
// ロード画面用フラグ
const [isTodosLoading, setIsTodosLoading] = useState(true);
// ログイン状態、アカウント情報のロード状態をAuthContextから取得
const { user, isAuthLoading } = useAuth();
useEffect(() => {
if (isAuthLoading) return;
//未ログインの場合は空配列で初期化し早期return
if (!user) {
dispatch({ type: "todo/init", payload: [] });
setIsTodosLoading(false);
return;
}
// ログイン中なら取得&スナップショット処理を呼び出す
const unsubscribe = subscribeUserTodos(
user.uid,
dispatch,
setIsTodosLoading
);
return () => unsubscribe();
}, [user, isAuthLoading]);
return (
<TodoContext.Provider value={{ todos: state, isTodosLoading }}>
<TodoContextDispatch.Provider value={dispatch}>
{children}
</TodoContextDispatch.Provider>
</TodoContext.Provider>
);
};
取得、スナップショットを行うsubscribeUserTodos
です。
todosの同期部分が以下です。
export function subscribeUserTodos(userId, dispatch, setLoading) {
const todosRef = collection(db, `users/${userId}/todos`);
const todosQ = query(todosRef, orderBy("order", "asc"));
const unsubscribeTodos = onSnapshot(
todosQ,
async (snapshot) => {
// 【A】リストの初期ロード+リセット
const rawLists = await Promise.all(
snapshot.docs.map(async (docSnap) => {
const data = { id: docSnap.id, ...docSnap.data() };
// 初回タスク一括フェッチ
const tasksSnap = await getDocs(
collection(db, `users/${userId}/todos/${docSnap.id}/tasks`)
);
const tasks = tasksSnap.docs
.map((td) => ({ id: td.id, ...td.data() }))
.sort((a, b) => a.order - b.order);
return { ...data, tasks };
})
);
dispatch({ type: "todo/init", payload: rawLists });
setLoading(false);
return () => {
unsubscribeTodos();
};
上記でtodos
のサブスクを開始しましたが、Firebaseの仕様上、これだけではサブコレクションに対する変更は同期されません。
今回のデータ構造としてtasks
をサブコレクションに分離して管理しているので、
タスクの追加/編集/削除/並び替え/チェックボックス切替に対する変更は同期されません。
先ほどのtodos
に加えてtasks
もサブスク対象にする必要があり、tasksはリストごとに生成されるので、リスト単位でサブスクの開始と解除関数を管理する必要があります。
// 【B】リスト単位でタスク購読の登録/解除 (todosに変更があったときに今回のリストのidを記録)
const nextListIds = snapshot.docs.map((d) => d.id); // 取得したリストのidでできた配列
// 登録:追加されたリストのタスクサブスク開始
nextListIds.forEach((listId) => {
if (!tasksUnsubscribes[listId]) {
const tasksRef = collection(db,`users/${userId}/todos/${listId}/tasks`);
const tasksQ = query(tasksRef, orderBy("order", "asc"));
tasksUnsubscribes[listId] = onSnapshot(tasksQ, (tasksSnap) => {
const tasks = tasksSnap.docs
.map((td) => ({ id: td.id, ...td.data() }))
.sort((a, b) => a.order - b.order);
dispatch({
type: "todo/update",
payload: { listId, updatedTasks: tasks },
});
});
}
});
// 解除:消えたリストのタスク購読をオフ
Object.keys(tasksUnsubscribes)
.filter((id) => !nextListIds.includes(id))
.forEach((oldId) => {
tasksUnsubscribes[oldId](); // 削除されたリストの購読を解除
delete tasksUnsubscribes[oldId]; // 解除用関数を格納しているオブジェクトから削除
});
},
(error) => {
console.error("subscribeUserTodos error:", error);
dispatch({ type: "todo/init", payload: [] });
setLoading(false);
}
);
// 返り値は「トップ+全サブスク解除」を行う関数
return () => {
unsubscribeTodos();
Object.values(tasksUnsubscribes).forEach((unsub) => unsub());
};
}
nextListIds
には更新直後の最新リストのIdが入り、prevListIds
には更新直前のリストIdが記録されています。
この2つを使ってtodos上でリストが増えた場合はサブスク対象に追加し、削除されたリストがあればサブスク対象から外すといった処理を行うようにしています。
これでリストとタスクへの変更はすべて同期され、ローカル上でも変更を即座に反映してくれます。
並び替え(ドラッグ&ドロップ)
Todoアプリを作る!と決めた時点でどうしてもドラッグ&ドロップ機能は欲しいと思っていました。
調べたところ比較的簡単に実現できそうな@dnd-kitを選択。リスト、タスクにそれぞれ実装していますが、今回はリストでの実装について解説します。
dnd-kitではドラッグの際に以下のイベントハンドラが用意されています。
-
onDragStart
:ドラッグ開始 -
onDragMove
:ドラッグ中 -
onDragOver
:ドラッグ中に別のドロップ可能エリアに移動 -
onDragEnd
:ドラッグの終了(ドロップ時) -
onDragCancel
:ドラッグの中断
縦か横方向のみの並べ替えであればonDragEnd
で最終的な並び順の更新をするだけで並べ替えを実装できます。
データをFirestoreに保存するとき、要素の並び順(index)までは記録されないので、リストごとに並び順を記録するプロパティを用意する必要があります。今回はorder
としました。
// 衝突先のカラムidを検索
const findColumn = (id) => {
if (!id) {
return null;
}
// カテゴリーのIDリストを作成
const categoryIds = CATEGORY_LIST.map((cat) => cat.id);
// カテゴリーIDが直接渡された場合はそのまま返す
if (categoryIds.includes(id)) {
return id;
}
// itemのidが渡された場合、そのitemが属するカラムのidを返す
return todosList.find((todo) => todo.id === id)?.category;
};
const handleDragEnd = async (event) => {
setDragItem(null);
const { active, over } = event;
const overId = String(over?.id);
const activeId = String(active.id);
const overColumn = findColumn(overId);
const activeColumn = findColumn(activeId);
let updatedTodos = [...todosList];
if (activeColumn === overColumn) {
const oldIndex = todosList.findIndex((t) => t.id === active.id);
const newIndex = todosList.findIndex((t) => t.id === over.id);
updatedTodos = arrayMove(todosList, oldIndex, newIndex);
updatedTodos = updatedTodos.map((todo, index) => ({
...todo,
order: index + 1, // 1から順に振り直す
}));
CATEGORY_LIST.forEach((cat) => {
const itemsInCat = updatedTodos.filter(
(todo) => todo.category === cat.id
);
itemsInCat.forEach((item, index) => {
const idx = updatedTodos.findIndex((todo) => todo.id === item.id);
if (idx !== -1) {
updatedTodos[idx] = { ...updatedTodos[idx], order: index + 1 };
}
});
});
setTodosList(updatedTodos);
if (!useId) {
dispatch({
type: "todo/sort",
payload: { updatedTodos },
});
} else {
await sortTodoList(userId, updatedTodos);
}
}
};
今回のようにマルチカラムでの並べ替えを実現するためには、リストごとにcategory
を設け、ドラッグアイテムと衝突したカラムにonDragOver
で書き換える必要があります。
//ドラッグ中にcategoryとorderを書き換え
const handleDragOver = (event) => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const overId = String(over.id);
const activeId = String(active.id);
const overColumn = findColumn(overId);
const activeColumn = findColumn(activeId);
let updatedTodos = [...todosList];
// ドラッグ元とターゲットが異なるカラムの場合
if (activeColumn !== overColumn) {
// カテゴリー変更
updatedTodos = updatedTodos.map((todo) =>
todo.id === activeId ? { ...todo, category: overColumn } : todo
);
// カテゴリーIDのリストを取得
const categoryIds = CATEGORY_LIST.map((cat) => cat.id);
// over.id がカラム自体を示している場合(=個々のアイテムと衝突していない場合)
if (categoryIds.includes(overId)) {
// ターゲットカラム内のアイテム(ドラッグ中のものを除く)を取得
const targetItems = updatedTodos
.filter(
(todo) => todo.category === overColumn && todo.id !== activeId
)
.sort((a, b) => a.order - b.order);
// 新規追加先は末尾とする(空なら先頭)
const newOrder = targetItems.length + 1;
updatedTodos = updatedTodos.map((todo) =>
todo.id === activeId ? { ...todo, order: newOrder } : todo
);
}
setTodosList(updatedTodos);
}
};
また、元の要素をドラッグしている最中にカテゴリー変更を行うと、カラムと重ねた瞬間に移動してしまい、快適に操作できません。そのためにonDragStartでドラッグアイテムのidを記録、元の要素と全く同じデザインのカードをオーバーレイとして呼び出し、それをドラッグに使用します。
// ドラッグするアイテムのidをstateにセット
const handleDragStart = useCallback((event) => {
const { active, over } = event;
if (!active) return;
setDragItem(active.id);
}, []);
dnd-kitにはドラッグの当たり判定ロジックが4つ用意されています。今回はpointerWithin
を採用。他のロジックだと空のカラムにドラッグしづらいのでマルチカラムには向いていないかなという理由です。
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="relative grid grid-cols-1 mt-3 mb-40 select-none md:mx-auto md:flex md:justify-center md:flex-nowrap">
{CATEGORY_LIST.map((cat) => {
const list = todosList.filter((t) => t.category === cat.id);
return (
<div key={cat.id} className="flex-none w-full px-2 pt-2 md:w-80">
<TodoColmun
category={cat.id}
todoList={list}
selectedTodoId={selectedTodoId}
openModal={openModal}
isTouch={isTouch}
/>
</div>
);
})}
// ドラッグ中のアイテムのidを渡して、オーバーレイを表示
<DragOverlay>
{dragItem && (
<Todo todo={todosList.find((t) => t.id === dragItem)} isOverlay />
)}
</DragOverlay>
</div>
</DndContext>
UI/UXのこだわり
メインページのデザイン
利用するユーザーが何度も見る画面なので、飽きさせないようなデザインを目指しました。ヘッダーにグラデーションカラーを採用し、各カラムのタイトル部分をパステルカラーにすることでサイト全体を軽快なイメージにしています。
フォーム
リスト作成に使う入力フォームはリストが増えた状態ではドラッグ中邪魔な位置にあるので、画面下側にマウスを移動させると下からスッとスライドするようにしています。フォーム上部のテキストは状態に応じて変更することでユーザーを誘導しています。スマホ等タッチデバイスの場合はタップでフォームがスライドし、フォーム上部のテキストも「タップでフォームが表示されます」に変わります。
リストカード
進捗がわかりやすいように割合で長さと色が変わる進捗バーを設けています。
一覧性を重視するため、詳細ページで変更した曜日ラベルやロック状態も一目でわかるようにしています。
レスポンシブ対応
レイアウト
Todoリストを出先でも手軽に管理できるようにしたかったのでこちらの対応もマストで行いました。
iPhone16で動作確認をしながら開発を進めました。
Tailwind CSS は「モバイルファースト(mobile-first)」の思想で設計されています。
モバイルファーストとは、まず最小幅(主にスマホサイズ)に最適化したスタイルをデフォルトとして書き、そこから画面幅が広がったときに追加・上書きする形でレスポンシブ対応を行う手法です。
Tailwind では、画面サイズによらず同じスタイルを当てるならこれでよいのですが、
<div class="bg-red-500">
モバイル、タブレット、PCでは赤になります
</div>
画面幅〇〇px 以上のデバイス向けにスタイルを切り替えたい場合は、以下のように
<!--
例:<div> をスマホサイズでは青背景にし、
画面幅768px(md ブレークポイント)以上では赤背景に変更する
-->
<div class="bg-blue-500 md:bg-red-500">
モバイルでは青、タブレットやPCでは赤になります
</div>
画面の大きなデバイスで当てたいスタイル側にmd:
やlg:
を付けることで可能になります。
ドラッグアンドドロップ
タッチデバイスでドラッグアンドドロップを使えるようにするには、sencors
に記述を追加する必要があります。
<DndContext
sensors={sensors} //ここ
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
//ドラッグ可能な要素
</DndContext>
//5px動かすとドラッグと判定する。
const sensors = useSensors(
//マウス
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
//スマホ
useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
})
);
タップとドラッグの区別をつけるための条件を指定します。
以下コードの解説です。
-
TouchSensor
:スマホやタブレットなどのタッチ操作によるドラッグを検出するセンサー -
activationConstraint
:ユーザーがドラッグ操作を開始するまでの条件 -
delay
:ドラッグ開始までの待機時間(ms) -
tolerance
:指を動かしてもいい距離の範囲(px)
上記を合わせて指が5px以上動く前に250ms経過すればドラッグを開始という条件になっています。
また、iPhoneのSafariの場合、画面の長押しで文字選択と拡大鏡が出てきてしまうので、
ドラッグアイテムのstyle
に以下を追記します。
<div
data-todo
onClick={() => (isOverlay ? undefined : openModal(todo.id))}
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
...style,
+ WebkitTouchCallout: "none", // iOS長押しメニュー無効
+ WebkitUserSelect: "none", // safari対策
+ userSelect: "none", // テキスト選択無効+拡大鏡無効
}}
さらに、ドラッグ中に左右にスクロールしないようにスタイルで制御します。
const style = {
transform: isSorting ? CSS.Translate.toString(transform) : undefined,
opacity: isDragging ? "0" : isOverlay ? "0.6" : "1",
+ touchAction: isDragging ? "none" : "pan-y",
transition,
};
none
ここまで制御すればネイティブアプリのような挙動なります。
苦労した点とその解決
デザイン
動的なアプリのためユーザーによってタイトルやタスクのコンテンツ量が異なるので、あらゆる要素でレイアウトが崩れないように気を使ってデザインを進めました。特にタスクは作成できる数に制限を設けていないので、数が多いときにもカードにそのまま表示するか迷いましたが、一定の高さでバッサリ切るようにしました。
PC画面
曜日リセット機能
機能紹介
各リストに「何曜日にタスク進捗をリセットするか」を示す resetDays
マップ(例:{ mon: true, tue: false, … }
)を持たせています。
アプリ起動時またはページリロード時に、今日がリセット対象の曜日かを判定し、該当リストの Firestore 上のタスクをすべて complete: false
に更新する機能です。
フィールド名 | 型 | 説明 |
---|---|---|
resetDay | Map | 各曜日に対してタスクの進捗を自動リセットするかどうかを指定(例:{ mon: true, tue: false, ... } |
課題
アプリを起動していないときも日付が変わった瞬間にリセットをしたかったのですが、それをするにはFirebaseのCloud Functionを使う必要があるようです。無料枠はあるのですが、利用するためにクレジットカードを登録して、無料枠を超えた利用をすると勝手に課金されるシステム(従量制) なので怖くて使えませんでした。
アプリを同じ日に起動し直したり、リロードをすると、該当リストが何度もリセットされてしまい邪魔になっていました。Cloud Functionを使って日付が変わった瞬間にリセットロジックを走らせるなら問題ないのですが、今回のフラグが起動時/リロード時なので工夫が必要です。
import { db } from "@/firebase/firebaseConfig";
import { collection, getDocs, updateDoc, doc } from "firebase/firestore";
import { getAuth } from "firebase/auth";
const dayNames = ["sun","mon","tue","wed","thu","fri","sat"];
const isSameJpDay = (a, b) => a.toLocaleDateString("ja-JP") === b.toLocaleDateString("ja-JP");
/**
* Firestore の complete フラグをリセットし、
* 更新後の todos 配列を返す
*/
export const resetTasksIfNeeded = async (todos) => {
const now = new Date();
const last = localStorage.getItem("lastResetDate");
if (last && isSameJpDay(new Date(last), now)) {
// 同日リセット済みならそのまま返却
return todos;
}
const userId = getAuth().currentUser?.uid;
const todayKey = dayNames[now.getDay()];
const updated = await Promise.all(
todos.map(async (todo) => {
if (!todo.resetDays?.[todayKey]) return todo;
const snap = await getDocs(
collection(db, `users/${userId}/todos/${todo.id}/tasks`)
);
if (snap.empty) return todo;
// Firestore 側を一括リセット
await Promise.all(
snap.docs.map((t) =>
updateDoc(
doc(db, `users/${userId}/todos/${todo.id}/tasks/${t.id}`),
{ complete: false }
)
)
);
// ローカル用に complete=false のタスク配列を再構築
const tasks = snap.docs.map((t) => ({
id: t.id,
...t.data(),
complete: false,
}));
return { ...todo, tasks };
})
);
// リセット実行日を保存
localStorage.setItem("lastResetDate", now.toISOString());
return updated;
};
対策
ローカルストレージに最終リセット日を記録し、起動/リロードのたびに重複してリロードが走らないように対策しました。
今後の課題と対策
localStorage は端末(ブラウザ)ごとに保存されるため、あるデバイスで既にリセット済みでも、別のデバイスではその情報を参照できず、同一日に再度リセットが実行されてしまいます。そのため、最終リセット日もFireStoreに保存して、参照に使うことで対策できると考えていますが、少し特殊な状況下で起きることなので実装は後回しにしました。
並び替えのスタイル
ドラッグ対象にはsetNodeRef
を渡し、ドラッグの開始時につかむ場所(グラブボタンなど)にはsetActiveNodeRef
を使う、というのを記事で見かけました。動かす対象はリストカードで、掴むのもカード全体だと考え、以下のようにリストカードに実装していました。
<div
data-todo
onClick={() => (isOverlay ? undefined : openModal(todo.id))}
ref={setNodeRef}
style={style}
>
<div
ref={setActivatorNodeRef}
style={style}
{...attributes}
{...listeners}
<div>以下カードのデザイン</div>
</div>
</div>
しかし、間違っていました。まず、今回の実装ではリストカード全体がグラブ判定を取るようにして、別途ハンドル部分を用意しないのでsetActiveNodeRef
は不要です。そして次が重大ミスなのですが、2つのdivにそれぞれsytle
を渡しています。
const style = {
transform: isSorting ? CSS.Translate.toString(transform) : undefined,
opacity: isDragging ? "0" : isOverlay ? "0.6" : "1",
touchAction: isDragging ? "none" : "pan-y",
transition,
};
この中にはドラッグ時のアイテムのデザインや衝突したアイテム同士のアニメーションなどが書かれています。
これが2重に当たっているせいで衝突した時のアニメーションも2倍になっていました。
そのことに気づかず、親要素からアイテムを出ないように制限したり、アニメーションを禁止するなど強引に制御しようと1か月ほど試行錯誤しましたが、下記のように不要なdivごと削除して解決しました。
<div
data-todo
onClick={() => (isOverlay ? undefined : openModal(todo.id))}
ref={setNodeRef}
+ {...attributes}
+ {...listeners}
style={style}
>
- <div
- ref={setActivatorNodeRef}
- style={style}
- {...attributes}
- {...listeners}
- >
<div>以下カードのデザイン</div>
- </div>
</div>
設計
アプリ完成間際に設計周りを作成しました。
しかし、行き当たりばったりで作り続けるよりも効果的でした。
改めて要件定義や画面遷移図を描くと、「実装した機能が本来の要件と合っているか」「抜け漏れがないか」を振り返るきっかけになりました。たとえば「本来はこういうフローが必要だったが、実装時に省略してしまった」「テーブル設計が要件に対して不十分だった」といった気づきが得られ、リファクタリングや追加実装の指針になります。将来的に増やすデータ構造や機能を明記したり、機能追加に伴って必要なUI/UXについても、画面遷移図でフローやデザインを決めておくなどによって開発がスムーズになり、その効果を実感しました。
今後の課題・改善点
改善点が結構あるので箇条書きにします。
時間かかりそうなもの
- TypeScriptの導入
- テスト
- コンポーネントの分離
細かい機能
- メールアドレスの本人確認:実在するメールアドレスか確認メールを送る機能を実装
- パスワードリセット機能:パスワードがわからなくなった時のケアを実装
- アカウント設定の充実:パスワードの変更など
- リストの複製:アプリ内で使える形式の複製の実装
- 予定日の設定:目標の日付を手入力かカレンダーで設定できる機能
- リマインダー:予定日の前日などに通知する機能
- 曜日リセット:FireStoreに最終リセット日を記録
- URL:リストやタスクに個別に商品ページやWebサイトのurlを保存
おわりに
作って学んだことのまとめ
今回は設計を行わずにアプリの開発に着手しました。というのもまだReactという環境に慣れておらず、どんな機能を入れられるかもわかっていませんでした。Firebaseにデータを保存することになったのも開発の途中からで、初期のころはjson-serverでファイルに書き込みを行う形でtodoデータを保存させていました。Next.jsも途中から乗り換えを行いましたが、いまいち使いこなせている感じがしませんでした。
そのため無駄な変更を何度も行い、開発効率を落としていました。
今回の開発を通じて、ReactやNext.jsの基本的な役割や動作を学んだだけでなく、どのような技術、フレームワーク、ライブラリ
、データ構造でということをしっかり決めておくことの大切さを痛感しました。
これからも様々なジャンルのアプリを作り、学んでいきたいと思います。
ここまでご覧いただきありがとうございました!
GitHub/デプロイ先リンクの再掲
デプロイURL:List-Board
Git-Hub:Git-Hub