forked from semi-23e/nextjs-todo-tutorial
step5-3 最適化まで終了
This commit is contained in:
186
src/app/page.tsx
186
src/app/page.tsx
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import TodoList from '@/components/TodoList';
|
import TodoList from '@/components/TodoList';
|
||||||
import FilterButtons from '@/components/FilterButtons';
|
import FilterButtons from '@/components/FilterButtons';
|
||||||
|
|
||||||
@@ -37,25 +37,27 @@ export default function Home() {
|
|||||||
const [error, setError] = useState<AppError | null>(null);
|
const [error, setError] = useState<AppError | null>(null);
|
||||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||||
|
|
||||||
// フィルターに応じたTodoの数をカウント
|
// フィルターに応じたTodoの数をメモ化
|
||||||
const activeCount = todos.filter(todo => !todo.completed).length;
|
const { activeCount, completedCount } = useMemo(() => ({
|
||||||
const completedCount = todos.filter(todo => todo.completed).length;
|
activeCount: todos.filter(todo => !todo.completed).length,
|
||||||
|
completedCount: todos.filter(todo => todo.completed).length,
|
||||||
|
}), [todos]);
|
||||||
|
|
||||||
// エラー処理用のヘルパー関数
|
// エラー処理用のヘルパー関数をメモ化
|
||||||
const handleError = (error: AppError) => {
|
const handleError = useCallback((error: AppError) => {
|
||||||
console.error('アプリケーションエラー:', error);
|
console.error('アプリケーションエラー:', error);
|
||||||
setError(error);
|
setError(error);
|
||||||
setShowErrorDialog(true);
|
setShowErrorDialog(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// エラーダイアログを閉じる
|
// エラーダイアログを閉じる
|
||||||
const closeErrorDialog = () => {
|
const closeErrorDialog = useCallback(() => {
|
||||||
setShowErrorDialog(false);
|
setShowErrorDialog(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// バリデーション関数
|
// バリデーション関数をメモ化
|
||||||
const validateTodoText = (text: string): boolean => {
|
const validateTodoText = useCallback((text: string): boolean => {
|
||||||
if (!text || text.trim().length === 0) {
|
if (!text || text.trim().length === 0) {
|
||||||
handleError({
|
handleError({
|
||||||
type: 'VALIDATION_ERROR',
|
type: 'VALIDATION_ERROR',
|
||||||
@@ -73,10 +75,10 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
}, [handleError]);
|
||||||
|
|
||||||
// Local Storage操作をtry-catchで包む
|
// Local Storage操作をメモ化
|
||||||
const safeLocalStorageGet = (key: string): string | null => {
|
const safeLocalStorageGet = useCallback((key: string): string | null => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,9 +88,9 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}, [handleError]);
|
||||||
|
|
||||||
const safeLocalStorageSet = (key: string, value: string): boolean => {
|
const safeLocalStorageSet = useCallback((key: string, value: string): boolean => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
return true;
|
return true;
|
||||||
@@ -99,14 +101,15 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
}, [handleError]);
|
||||||
|
|
||||||
const startEdit = (todo: Todo) => {
|
// 編集関連の関数をメモ化
|
||||||
|
const startEdit = useCallback((todo: Todo) => {
|
||||||
setEditingId(todo.id);
|
setEditingId(todo.id);
|
||||||
setEditText(todo.text);
|
setEditText(todo.text);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const saveEdit = () => {
|
const saveEdit = useCallback(() => {
|
||||||
if (!validateTodoText(editText)) {
|
if (!validateTodoText(editText)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -128,10 +131,15 @@ export default function Home() {
|
|||||||
message: 'TODOの更新に失敗しました'
|
message: 'TODOの更新に失敗しました'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [editText, editingId, todos, validateTodoText, handleError]);
|
||||||
|
|
||||||
// 新しいTODOを追加する関数(エラーハンドリング追加)
|
const cancelEdit = useCallback(() => {
|
||||||
const addTodo = () => {
|
setEditingId(null);
|
||||||
|
setEditText('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// TODO操作の関数をメモ化
|
||||||
|
const addTodo = useCallback(() => {
|
||||||
if (!validateTodoText(inputValue)) {
|
if (!validateTodoText(inputValue)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,7 +150,7 @@ export default function Home() {
|
|||||||
text: inputValue.trim(),
|
text: inputValue.trim(),
|
||||||
completed: false
|
completed: false
|
||||||
};
|
};
|
||||||
setTodos([...todos, newTodo]);
|
setTodos(prev => [...prev, newTodo]);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError({
|
handleError({
|
||||||
@@ -150,48 +158,38 @@ export default function Home() {
|
|||||||
message: 'TODOの追加に失敗しました'
|
message: 'TODOの追加に失敗しました'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [inputValue, validateTodoText, handleError]);
|
||||||
|
|
||||||
const cancelEdit = () => {
|
const handleSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||||
setEditingId(null);
|
|
||||||
setEditText('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// フォーム送信時の処理
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTodo();
|
addTodo();
|
||||||
};
|
}, [addTodo]);
|
||||||
|
|
||||||
// Todoの完了状態をトグルする関数
|
const toggleTodo = useCallback((id: number) => {
|
||||||
const toggleTodo = (id: number) => {
|
|
||||||
try {
|
try {
|
||||||
const updatedTodos = todos.map(todo => {
|
setTodos(prev => prev.map(todo => {
|
||||||
if (todo.id === id) {
|
if (todo.id === id) {
|
||||||
return { ...todo, completed: !todo.completed };
|
return { ...todo, completed: !todo.completed };
|
||||||
}
|
}
|
||||||
return todo;
|
return todo;
|
||||||
});
|
}));
|
||||||
setTodos(updatedTodos);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError({
|
handleError({
|
||||||
type: 'VALIDATION_ERROR',
|
type: 'VALIDATION_ERROR',
|
||||||
message: 'TODO状態の更新に失敗しました'
|
message: 'TODO状態の更新に失敗しました'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [handleError]);
|
||||||
|
|
||||||
// 削除ボタンが押されたときの処理(確認ダイアログ表示)
|
const handleDeleteClick = useCallback((id: number) => {
|
||||||
const handleDeleteClick = (id: number) => {
|
|
||||||
setShowConfirm(true);
|
setShowConfirm(true);
|
||||||
setTargetId(id);
|
setTargetId(id);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// ダイアログで「はい」が押されたときの本削除処理
|
const confirmDelete = useCallback(() => {
|
||||||
const confirmDelete = () => {
|
|
||||||
if (targetId !== null) {
|
if (targetId !== null) {
|
||||||
try {
|
try {
|
||||||
setTodos(todos.filter(todo => todo.id !== targetId));
|
setTodos(prev => prev.filter(todo => todo.id !== targetId));
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
setTargetId(null);
|
setTargetId(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -201,64 +199,30 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [targetId, handleError]);
|
||||||
|
|
||||||
// ダイアログで「いいえ」が押されたときの処理
|
const cancelDelete = useCallback(() => {
|
||||||
const cancelDelete = () => {
|
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
setTargetId(null);
|
setTargetId(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 空欄入力時ダイアログを閉じる関数
|
const closeInputAlert = useCallback(() => {
|
||||||
const closeInputAlert = () => {
|
|
||||||
setShowInputAlert(false);
|
setShowInputAlert(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// 完了済みTODOをすべて削除
|
const clearCompleted = useCallback(() => {
|
||||||
const clearCompleted = () => {
|
|
||||||
try {
|
try {
|
||||||
setTodos(todos.filter(todo => !todo.completed));
|
setTodos(prev => prev.filter(todo => !todo.completed));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError({
|
handleError({
|
||||||
type: 'VALIDATION_ERROR',
|
type: 'VALIDATION_ERROR',
|
||||||
message: '完了済みTODOの削除に失敗しました'
|
message: '完了済みTODOの削除に失敗しました'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [handleError]);
|
||||||
|
|
||||||
// アプリ起動時にデータを読み込み(エラーハンドリング強化)
|
// エクスポート・インポート機能をメモ化
|
||||||
useEffect(() => {
|
const exportData = useCallback(() => {
|
||||||
const savedTodos = safeLocalStorageGet(STORAGE_KEY);
|
|
||||||
if (savedTodos) {
|
|
||||||
try {
|
|
||||||
const parsedTodos = JSON.parse(savedTodos);
|
|
||||||
|
|
||||||
// データの妥当性をチェック
|
|
||||||
if (Array.isArray(parsedTodos)) {
|
|
||||||
const validTodos = parsedTodos.filter(todo =>
|
|
||||||
todo &&
|
|
||||||
typeof todo.id === 'number' &&
|
|
||||||
typeof todo.text === 'string' &&
|
|
||||||
typeof todo.completed === 'boolean'
|
|
||||||
);
|
|
||||||
setTodos(validTodos);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError({
|
|
||||||
type: 'STORAGE_ERROR',
|
|
||||||
message: '保存されたデータの形式が正しくありません'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// TODOが変更されるたびに保存(エラーハンドリング追加)
|
|
||||||
useEffect(() => {
|
|
||||||
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos));
|
|
||||||
}, [todos]);
|
|
||||||
|
|
||||||
// データのエクスポート機能(エラーハンドリング追加)
|
|
||||||
const exportData = () => {
|
|
||||||
try {
|
try {
|
||||||
const dataStr = JSON.stringify(todos, null, 2);
|
const dataStr = JSON.stringify(todos, null, 2);
|
||||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||||
@@ -274,10 +238,9 @@ export default function Home() {
|
|||||||
message: 'データのエクスポートに失敗しました'
|
message: 'データのエクスポートに失敗しました'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, [todos, handleError]);
|
||||||
|
|
||||||
// データのインポート機能(エラーハンドリング強化)
|
const importData = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const importData = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
@@ -285,7 +248,6 @@ export default function Home() {
|
|||||||
try {
|
try {
|
||||||
const importedData = JSON.parse(e.target?.result as string);
|
const importedData = JSON.parse(e.target?.result as string);
|
||||||
|
|
||||||
// インポートデータの妥当性をチェック
|
|
||||||
if (!Array.isArray(importedData)) {
|
if (!Array.isArray(importedData)) {
|
||||||
throw new Error('無効なデータ形式です');
|
throw new Error('無効なデータ形式です');
|
||||||
}
|
}
|
||||||
@@ -302,8 +264,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTodos(validTodos);
|
setTodos(validTodos);
|
||||||
|
|
||||||
// ファイル入力をリセット
|
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError({
|
handleError({
|
||||||
@@ -324,7 +284,41 @@ export default function Home() {
|
|||||||
|
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
}
|
}
|
||||||
};
|
}, [handleError]);
|
||||||
|
|
||||||
|
// アプリ起動時にデータを読み込み
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTodos = safeLocalStorageGet(STORAGE_KEY);
|
||||||
|
if (savedTodos) {
|
||||||
|
try {
|
||||||
|
const parsedTodos = JSON.parse(savedTodos);
|
||||||
|
|
||||||
|
if (Array.isArray(parsedTodos)) {
|
||||||
|
const validTodos = parsedTodos.filter(todo =>
|
||||||
|
todo &&
|
||||||
|
typeof todo.id === 'number' &&
|
||||||
|
typeof todo.text === 'string' &&
|
||||||
|
typeof todo.completed === 'boolean'
|
||||||
|
);
|
||||||
|
setTodos(validTodos);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError({
|
||||||
|
type: 'STORAGE_ERROR',
|
||||||
|
message: '保存されたデータの形式が正しくありません'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [safeLocalStorageGet, handleError]);
|
||||||
|
|
||||||
|
// TODOが変更されるたびに保存(デバウンス処理付き)
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
safeLocalStorageSet(STORAGE_KEY, JSON.stringify(todos));
|
||||||
|
}, 300); // 300ms後に保存
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [todos, safeLocalStorageSet]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen p-8 bg-gray-50">
|
<main className="min-h-screen p-8 bg-gray-50">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type FilterButtonsProps = {
|
|||||||
onClearCompleted: () => void;
|
onClearCompleted: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FilterButtons({
|
const FilterButtons = React.memo(function FilterButtons({
|
||||||
filter,
|
filter,
|
||||||
totalCount,
|
totalCount,
|
||||||
activeCount,
|
activeCount,
|
||||||
@@ -64,4 +64,6 @@ export default function FilterButtons({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default FilterButtons;
|
||||||
@@ -18,7 +18,7 @@ type TodoItemProps = {
|
|||||||
onEditTextChange: (text: string) => void;
|
onEditTextChange: (text: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TodoItem({
|
const TodoItem = React.memo(function TodoItem({
|
||||||
todo,
|
todo,
|
||||||
isEditing,
|
isEditing,
|
||||||
editText,
|
editText,
|
||||||
@@ -29,6 +29,11 @@ export default function TodoItem({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onEditTextChange,
|
onEditTextChange,
|
||||||
}: TodoItemProps) {
|
}: TodoItemProps) {
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') onSaveEdit();
|
||||||
|
if (e.key === 'Escape') onCancelEdit();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
<div className="flex items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
<input
|
<input
|
||||||
@@ -37,16 +42,14 @@ export default function TodoItem({
|
|||||||
onChange={onToggle}
|
onChange={onToggle}
|
||||||
className="mr-3 h-5 w-5 cursor-pointer"
|
className="mr-3 h-5 w-5 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
// 編集モード
|
// 編集モード
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => onEditTextChange(e.target.value)}
|
onChange={(e) => onEditTextChange(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={handleKeyDown}
|
||||||
if (e.key === 'Enter') onSaveEdit();
|
|
||||||
if (e.key === 'Escape') onCancelEdit();
|
|
||||||
}}
|
|
||||||
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="flex-1 border border-blue-500 p-2 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -61,6 +64,7 @@ export default function TodoItem({
|
|||||||
{todo.text}
|
{todo.text}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
// 編集モードのボタン
|
// 編集モードのボタン
|
||||||
<div className="flex gap-2 ml-2">
|
<div className="flex gap-2 ml-2">
|
||||||
@@ -96,4 +100,6 @@ export default function TodoItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default TodoItem;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import TodoItem from './TodoItem';
|
import TodoItem from './TodoItem';
|
||||||
|
|
||||||
type Todo = {
|
type Todo = {
|
||||||
@@ -22,7 +22,7 @@ type TodoListProps = {
|
|||||||
onEditTextChange: (text: string) => void;
|
onEditTextChange: (text: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TodoList({
|
const TodoList = React.memo(function TodoList({
|
||||||
todos,
|
todos,
|
||||||
filter,
|
filter,
|
||||||
editingId,
|
editingId,
|
||||||
@@ -34,7 +34,8 @@ export default function TodoList({
|
|||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onEditTextChange,
|
onEditTextChange,
|
||||||
}: TodoListProps) {
|
}: TodoListProps) {
|
||||||
const getFilteredTodos = () => {
|
// フィルタリング処理をメモ化
|
||||||
|
const filteredTodos = useMemo(() => {
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'active':
|
case 'active':
|
||||||
return todos.filter(todo => !todo.completed);
|
return todos.filter(todo => !todo.completed);
|
||||||
@@ -43,18 +44,18 @@ export default function TodoList({
|
|||||||
default:
|
default:
|
||||||
return todos;
|
return todos;
|
||||||
}
|
}
|
||||||
};
|
}, [todos, filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{getFilteredTodos().length === 0 ? (
|
{filteredTodos.length === 0 ? (
|
||||||
<p className="text-gray-500 text-center py-8">
|
<p className="text-gray-500 text-center py-8">
|
||||||
{filter === 'active' && 'すべて完了しました!'}
|
{filter === 'active' && 'すべて完了しました!'}
|
||||||
{filter === 'completed' && '完了したTODOがありません'}
|
{filter === 'completed' && '完了したTODOがありません'}
|
||||||
{filter === 'all' && 'TODOがありません'}
|
{filter === 'all' && 'TODOがありません'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
getFilteredTodos().map((todo) => (
|
filteredTodos.map((todo) => (
|
||||||
<TodoItem
|
<TodoItem
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
todo={todo}
|
todo={todo}
|
||||||
@@ -71,4 +72,6 @@ export default function TodoList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default TodoList;
|
||||||
Reference in New Issue
Block a user